sillyspec 3.24.8 → 3.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,7 @@ import { existsSync, readdirSync, readFileSync } from 'fs'
9
9
  import { join, basename } from 'path'
10
10
  import { execFileSync } from 'child_process'
11
11
  import { detectChangeRisk, checkIntegrationEvidence } from './change-risk-profile.js'
12
- import { SCAN_REQUIRED_DOCS } from './constants.js'
12
+ import { SCAN_REQUIRED_DOCS, AUXILIARY_STAGES } from './constants.js'
13
13
 
14
14
  /**
15
15
  * 校验结果
@@ -691,10 +691,8 @@ function validateExecuteOutputs(cwd, changeName, context = {}) {
691
691
  */
692
692
  const mainFlowStages = ['brainstorm', 'plan', 'execute', 'verify']
693
693
 
694
- /**
695
- * 辅助阶段(可独立运行,无严格转换顺序)
696
- */
697
- const auxiliaryStages = ['scan', 'quick', 'explore', 'archive', 'status', 'doctor']
694
+ // 辅助阶段(可独立运行,无严格转换顺序)定义在 constants.js 的 AUXILIARY_STAGES,
695
+ // 与 stages/index.js 共用单一真相源,避免两处逐字重复分叉。
698
696
 
699
697
  /**
700
698
  * @type {Object<string, StageContract>}
@@ -805,7 +803,7 @@ export function checkTransition(fromStage, toStage, options = {}) {
805
803
  }
806
804
 
807
805
  // 辅助阶段随时可执行(archive 除外:从主流程进入 archive 需要校验)
808
- if (auxiliaryStages.includes(toStage) && toStage !== 'archive') {
806
+ if (AUXILIARY_STAGES.includes(toStage) && toStage !== 'archive') {
809
807
  return { allowed: true }
810
808
  }
811
809
 
@@ -831,14 +829,14 @@ export function checkTransition(fromStage, toStage, options = {}) {
831
829
  return { allowed: true }
832
830
  }
833
831
  // 独立运行 archive(无前置)也允许
834
- if (!fromStage || auxiliaryStages.includes(fromStage)) {
832
+ if (!fromStage || AUXILIARY_STAGES.includes(fromStage)) {
835
833
  return { allowed: true }
836
834
  }
837
835
  return { allowed: false, reason: 'archive 的前置阶段是 verify,不能从 ' + fromStage + ' 跳转' }
838
836
  }
839
837
 
840
838
  // 从辅助阶段进入主流程:允许
841
- if (auxiliaryStages.includes(fromStage)) {
839
+ if (AUXILIARY_STAGES.includes(fromStage)) {
842
840
  return { allowed: true }
843
841
  }
844
842
 
@@ -892,4 +890,4 @@ export function runValidators(stageName, cwd, changeName, context = {}) {
892
890
  /**
893
891
  * 获取所有主流程阶段
894
892
  */
895
- export { mainFlowStages, auxiliaryStages }
893
+ export { mainFlowStages, AUXILIARY_STAGES as auxiliaryStages }
@@ -26,4 +26,4 @@ export const stageRegistry = {
26
26
  }
27
27
 
28
28
  // 辅助命令(在无活跃变更时也可执行)
29
- export const auxiliaryStages = ['scan', 'quick', 'explore', 'archive', 'status', 'doctor']
29
+ export { AUXILIARY_STAGES as auxiliaryStages } from '../constants.js'
@@ -18,6 +18,7 @@
18
18
  import { execSync } from 'child_process'
19
19
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
20
20
  import { join } from 'path'
21
+ import { verifyApiParity } from './contract-matrix.js'
21
22
 
22
23
  // 测试命令最长执行时间;超时视为失败(防止 CLI 被挂起的测试卡死)
23
24
  const TEST_TIMEOUT_MS = Number(process.env.SILLYSPEC_TEST_TIMEOUT_MS) || 10 * 60 * 1000
@@ -504,3 +505,43 @@ export function printVerifyTestCheck(result) {
504
505
  console.log(`📄 实测结果已写入: ${result.resultPath}`)
505
506
  }
506
507
  }
508
+
509
+ /**
510
+ * API parity 对账(advisory):前端调用 vs execute 提取的后端 endpoint artifact。
511
+ * 三态:skipped(无 provider artifact → 非全栈项目不打扰)/ warning(missingBackend>0)/ passed。
512
+ * 启发式正则、多假阳源 → advisory 不阻断 verify 完成(与 SillySpec 确定性校验定位一致)。
513
+ *
514
+ * @param {object} opts
515
+ * @param {string} opts.cwd - 扫描前端调用的根(主工作区,verify 时代码在此)
516
+ * @param {string} opts.specBase
517
+ * @param {string|null} [opts.changeName]
518
+ * @param {string|null} [opts.runtimeRoot]
519
+ * @returns {{ status: 'skipped'|'warning'|'passed', missingBackend: Array, unusedBackend: Array, summary: string, reason: string|null }}
520
+ */
521
+ export function runVerifyParityCheck({ cwd, specBase, changeName = null, runtimeRoot = null }) {
522
+ const r = verifyApiParity(specBase, cwd, runtimeRoot, changeName)
523
+ // 无 provider artifact(execute 未提取 / 非后端项目)→ 不打扰
524
+ if (r.backendCount === 0) {
525
+ return { status: 'skipped', missingBackend: [], unusedBackend: [], summary: r.summary, reason: '无后端契约 artifact(非全栈项目或 execute 未提取端点)' }
526
+ }
527
+ if (r.missingBackend.length > 0) {
528
+ return { status: 'warning', missingBackend: r.missingBackend, unusedBackend: r.unusedBackend, summary: r.summary, reason: null }
529
+ }
530
+ return { status: 'passed', missingBackend: [], unusedBackend: r.unusedBackend, summary: r.summary, reason: null }
531
+ }
532
+
533
+ /** 打印 parity 对账结果(advisory,不阻断) */
534
+ export function printVerifyParityCheck(result) {
535
+ if (result.status === 'skipped') return // 静默,不打扰非全栈项目
536
+ if (result.status === 'passed') {
537
+ console.log(`\n✅ API parity 对账通过:${result.summary}`)
538
+ return
539
+ }
540
+ // warning
541
+ console.warn(`\n⚠️ API parity 对账发现 ${result.missingBackend.length} 个前端调用无对应后端端点(advisory,不阻断归档):`)
542
+ for (const m of result.missingBackend.slice(0, 20)) {
543
+ console.warn(` - ${m.method} ${m.path} ← ${m.consumerFile}:${m.consumerLine}`)
544
+ }
545
+ if (result.missingBackend.length > 20) console.warn(` …还有 ${result.missingBackend.length - 20} 个`)
546
+ console.warn(' 提示:检查是否后端漏实现,或前端调用了尚未实现的端点。确认无误可在 design.md 标注豁免。')
547
+ }
@@ -49,14 +49,34 @@ export function filterDeliverableFiles(files) {
49
49
  }
50
50
 
51
51
  /**
52
- * 获取文件在 git 中的 blob hash(基于某个 commit/tree)
53
- * @param {string} cwd - git 工作区路径
54
- * @param {string} treeish - commit hash、分支等
55
- * @param {string} filePath - 相对路径
56
- * @returns {string|null} blob hash,文件不存在返回 null
52
+ * 批量获取多个文件在某个 treeish 中的 blob hash(一次 git ls-tree 替代 N 次 rev-parse)。
53
+ *
54
+ * 语义等价于对每个文件调 `git rev-parse <treeish>:<path>`:
55
+ * - 文件在 tree 中 → Map<f, hash>(与 rev-parse 返回的同一 blob hash)
56
+ * - 文件不在 tree 中 → 不在 Map 中(调用方 map.get(f) ?? null 得 null,等同 rev-parse 失败→null)
57
+ * ls-tree 输出 "<mode> <type> <hash>\t<path>"。path 恒为文件路径(来自 git diff/ls-files),
58
+ * 非目录,故不带 -r 也正确——文件在 tree 时 rev-parse treeish:path 与 ls-tree 都给同一 hash,
59
+ * 不在时都不给,等价。(沿用 git() 字符串拼接模式,不引入新的引号/空格破法。)
60
+ *
61
+ * @param {string} cwd
62
+ * @param {string} treeish
63
+ * @param {string[]} files
64
+ * @returns {Map<string, string>} path → blob hash(仅含存在于 tree 中的文件)
57
65
  */
58
- function getFileBlobHash(cwd, treeish, filePath) {
59
- return gitQuiet(cwd, `rev-parse ${treeish}:${filePath}`);
66
+ function getBlobHashMap(cwd, treeish, files) {
67
+ const map = new Map();
68
+ if (files.length === 0) return map; // 空 pathspec 会让 ls-tree 列出整棵树,必须拦截
69
+ const raw = gitQuiet(cwd, `ls-tree ${treeish} -- ${files.join(' ')}`);
70
+ if (!raw) return map;
71
+ for (const line of raw.split('\n')) {
72
+ if (!line) continue;
73
+ const tabIdx = line.indexOf('\t');
74
+ if (tabIdx === -1) continue;
75
+ const filePath = line.slice(tabIdx + 1);
76
+ const hash = line.slice(0, tabIdx).split(' ')[2]; // "<mode> <type> <hash>"
77
+ if (hash) map.set(filePath, hash);
78
+ }
79
+ return map;
60
80
  }
61
81
 
62
82
  /**
@@ -203,10 +223,14 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
203
223
  }
204
224
 
205
225
  // 5b. 对比 worktree 的 baseHash 和主工作区 HEAD 中每个清单文件的 blob hash
226
+ // 批量化:两次 ls-tree(worktree 的 baseHash + 主仓库 HEAD)各建一张 path→hash Map,
227
+ // 替代 per-file getFileBlobHash × 2(原 2N spawn → 固定 2)。语义等价见 getBlobHashMap。
206
228
  const targetFiles = hasAllowList ? [...allowSet] : changedFiles;
229
+ const wtHashMap = getBlobHashMap(worktreePath, baseHash, targetFiles);
230
+ const mainHashMap = getBlobHashMap(projectRoot, 'HEAD', targetFiles);
207
231
  for (const f of targetFiles) {
208
- const wtBlob = getFileBlobHash(worktreePath, baseHash, f);
209
- const mainBlob = getFileBlobHash(projectRoot, 'HEAD', f);
232
+ const wtBlob = wtHashMap.get(f) ?? null;
233
+ const mainBlob = mainHashMap.get(f) ?? null;
210
234
 
211
235
  // 两者都为 null(文件在 base 时不存在)→ OK
212
236
  if (wtBlob === null && mainBlob === null) continue;
@@ -234,7 +258,6 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
234
258
  const patchFiles = hasAllowList
235
259
  ? [...allowSet].filter(f => changedFiles.includes(f))
236
260
  : changedFiles;
237
- const fileArgs = patchFiles.length > 0 ? `-- ${patchFiles.join(' ')}` : '';
238
261
 
239
262
  // 创建临时文件
240
263
  const tmpDir = mkdtempSync(join(tmpdir(), 'sillyspec-patch-'));
@@ -245,14 +268,18 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
245
268
  let patchContent = '';
246
269
 
247
270
  // 分 tracked 变更和 untracked 新文件生成 patch
248
- const trackedFiles = patchFiles.filter(f => {
249
- // 文件在 diffBase 的 tree 中存在 → tracked(包括 rename 目标可能的情况)
250
- if (gitQuiet(worktreePath, `cat-file -e ${diffBase}:${f}`) !== null) return true;
251
- // 文件在工作区 index 中已存在(比如被 git mv 处理过)→ 也视为 tracked
252
- if (gitQuiet(worktreePath, `ls-files --error-unmatch ${f}`) !== null) return true;
253
- return false;
254
- });
255
- const untrackedPatchFiles = patchFiles.filter(f => !trackedFiles.includes(f));
271
+ // 批量化:一次 ls-tree(diffBase tree 中存在的文件)+ 一次 ls-files(index 中存在的文件)
272
+ // 建集合,替代 per-file cat-file -e / ls-files --error-unmatch(原至多 2N spawn → 固定 2)。
273
+ // 语义等价:cat-file -e diffBase:f 成功 ⟺ f 在 ls-tree diffBase 输出(getBlobHashMap key);
274
+ // ls-files --error-unmatch f 成功 ⟺ f 在 ls-files -- 输出。
275
+ const inTree = getBlobHashMap(worktreePath, diffBase, patchFiles);
276
+ const inIndexList = patchFiles.length > 0
277
+ ? (gitQuiet(worktreePath, `ls-files -- ${patchFiles.join(' ')}`) || '').split('\n').filter(Boolean)
278
+ : [];
279
+ const inIndex = new Set(inIndexList);
280
+ const trackedFiles = patchFiles.filter(f => inTree.has(f) || inIndex.has(f));
281
+ const trackedSet = new Set(trackedFiles);
282
+ const untrackedPatchFiles = patchFiles.filter(f => !trackedSet.has(f));
256
283
 
257
284
  // tracked 文件:git diff baseHash
258
285
  if (trackedFiles.length > 0) {
package/src/worktree.js CHANGED
@@ -18,6 +18,11 @@ const WORKTREES_REL = '.sillyspec/.runtime/worktrees';
18
18
  const BRANCH_PREFIX = 'sillyspec/';
19
19
  const META_FILE = 'meta.json';
20
20
 
21
+ // 进程级缓存:cwd → 主仓库根目录。避免每次 new WorktreeManager 都 spawn 一次
22
+ // `git rev-parse --git-common-dir`(Windows 上每次 spawn 约 30-100ms,execute 一次命令
23
+ // 会 new 多个 WorktreeManager)。git-common-dir 在进程内对同一 cwd 稳定,缓存安全。
24
+ const _mainRepoRootByCwd = new Map();
25
+
21
26
  /**
22
27
  * 检测当前目录的隔离状态
23
28
  * 返回 { inWorktree: boolean, inSubmodule: boolean }
@@ -224,24 +229,27 @@ export class WorktreeManager {
224
229
  * @private
225
230
  */
226
231
  _resolveMainRepoRoot() {
232
+ const cached = _mainRepoRootByCwd.get(this.cwd);
233
+ if (cached !== undefined) return cached;
234
+
235
+ let root = this.cwd;
227
236
  try {
228
237
  // git-common-dir 在主仓库内 = <main>/.git
229
238
  // 在 linked worktree 内 = <main>/.git(git 共享 .git 目录)
230
239
  const commonDir = gitQuiet(this.cwd, 'rev-parse --git-common-dir');
231
- if (!commonDir) return this.cwd;
232
-
233
240
  // commonDir 应该是 <main-repo>/.git
234
241
  // dirname(commonDir) = <main-repo>
235
- if (existsSync(commonDir)) {
242
+ if (commonDir && existsSync(commonDir)) {
236
243
  const st = statSync(commonDir);
237
244
  if (st.isDirectory()) {
238
- return dirname(commonDir);
245
+ root = dirname(commonDir);
239
246
  }
240
247
  }
241
248
  } catch (e) {
242
249
  // 静默 fallback:主仓库内执行或 git 异常
243
250
  }
244
- return this.cwd;
251
+ _mainRepoRootByCwd.set(this.cwd, root);
252
+ return root;
245
253
  }
246
254
 
247
255
  /**