sillyspec 3.28.9 → 3.28.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 +223 -222
- package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
- package/.claude/skills/sillyspec-quick/SKILL.md +8 -10
- package/.claude/skills/sillyspec-verify/SKILL.md +91 -87
- package/package.json +1 -1
- package/src/change-list.js +73 -20
- package/src/change-risk-profile.js +51 -3
- package/src/config-schema.js +51 -0
- package/src/contract-matrix.js +84 -5
- package/src/cross-repo-reconcile.js +134 -0
- package/src/design-facts.js +94 -18
- package/src/docs-check.js +14 -10
- package/src/docs-debt.js +7 -2
- package/src/docs-gate.js +42 -15
- package/src/endpoint-extractor.js +50 -2
- package/src/foreign-declared.js +25 -16
- package/src/hooks/worktree-guard.js +75 -2
- package/src/index.js +147 -2
- package/src/knowledge-classify.js +20 -1
- package/src/machine-interface.js +26 -0
- package/src/probe7-anchor-check.js +87 -0
- package/src/progress/stage-machine.js +34 -5
- package/src/progress.js +1 -1
- package/src/quicklog.js +240 -5
- package/src/run/command.js +25 -2
- package/src/run/complete-handlers.js +172 -1
- package/src/run/complete.js +143 -11
- package/src/run/gate-snapshot.js +372 -2
- package/src/run/gates.js +130 -12
- package/src/run/prompt.js +166 -7
- package/src/run/shared.js +81 -1
- package/src/scope-audit.js +101 -31
- package/src/stage-contract-spec.js +14 -0
- package/src/stage-contract.js +314 -8
- package/src/stage-review-checklist.js +3 -1
- package/src/stage-review.js +137 -2
- package/src/stages/archive.js +9 -5
- package/src/stages/brainstorm.js +7 -1
- package/src/stages/cmd-existence.js +9 -3
- package/src/stages/execute.js +62 -8
- package/src/stages/knowledge.js +24 -3
- package/src/stages/plan-postcheck.js +74 -10
- package/src/stages/plan.js +8 -1
- package/src/stages/quick.js +5 -5
- package/src/stages/verify.js +3 -2
- package/src/sync-noise.js +57 -1
- package/src/sync.js +77 -4
- package/src/task-review.js +84 -29
- package/src/verify-facts-schema.js +78 -9
- package/src/verify-postcheck.js +439 -68
- package/src/verify-probes.js +1156 -45
- package/src/worktree-apply.js +323 -8
- package/src/worktree-deps.js +24 -1
- package/src/worktree.js +274 -10
- package/src/write-audit.js +55 -0
package/src/design-facts.js
CHANGED
|
@@ -25,6 +25,7 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
|
25
25
|
import { join } from 'node:path'
|
|
26
26
|
import { parseDecisions } from './decision-distill.js'
|
|
27
27
|
import { parseFileChangeListDetailed } from './change-list.js'
|
|
28
|
+
import { parseDesignCoverageByRepo, parseRepoRegistry } from './stages/plan-postcheck.js'
|
|
28
29
|
import { parseModuleMapSimple } from './modules.js'
|
|
29
30
|
|
|
30
31
|
// ---------------------------------------------------------------------------
|
|
@@ -331,7 +332,8 @@ export function generateDesignSkeleton({ changeName, decisionsText, author, now
|
|
|
331
332
|
lines.push('')
|
|
332
333
|
lines.push('| 操作 | 文件路径 | 说明 |')
|
|
333
334
|
lines.push('|---|---|---|')
|
|
334
|
-
lines.push('<!-- 示例行(替换为实际清单后删除本注释):| 新增 | src/xxx/NewFile.java | 说明(含对外字段时交代 producer→consumer 数据流) | -->')
|
|
335
|
+
lines.push('<!-- 示例行(替换为实际清单后删除本注释):| 新增 | NEW:src/xxx/NewFile.java | 说明(含对外字段时交代 producer→consumer 数据流) | -->')
|
|
336
|
+
lines.push('<!-- 路径存在性核验铁律:计划新建的文件路径必须带 NEW: 前缀(如上例),已有文件写仓根相对正斜杠路径——末步 --done 门禁逐一核验存在性,无前缀的新建路径按 design_file_ref_invalid 拦截返工(2026-09-17 用户反馈⑥-①,写作期即提示) -->')
|
|
335
337
|
lines.push('')
|
|
336
338
|
|
|
337
339
|
lines.push('## 接口定义')
|
|
@@ -423,25 +425,99 @@ export function validateDesignFileList({ changeDir, cwd } = {}) {
|
|
|
423
425
|
if (!existsSync(designPath)) {
|
|
424
426
|
return { ok: true, errors, warnings: ['design 清单核验跳过:design.md 不存在'] }
|
|
425
427
|
}
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
428
|
+
|
|
429
|
+
// 单段存在性核验内核:NEW:/glob/占位符口径与原实现一致,仅把「根」参数化
|
|
430
|
+
const checkAgainstRoot = (root, rawPaths, repoLabel) => {
|
|
431
|
+
for (const raw of rawPaths) {
|
|
432
|
+
const rawPath = String(raw || '').trim()
|
|
433
|
+
if (!rawPath) continue
|
|
434
|
+
if (rawPath.startsWith('NEW:')) continue // 计划新建豁免(与 validateTargetFiles 同语义)
|
|
435
|
+
if (rawPath.includes('*') || rawPath.includes('?')) {
|
|
436
|
+
warnings.push(`design 清单条目含 glob 字符跳过存在性核验:${rawPath}`)
|
|
437
|
+
continue
|
|
438
|
+
}
|
|
439
|
+
const normalized = stripPathPlaceholders(rawPath)
|
|
440
|
+
if (!normalized || normalized === '.') continue
|
|
441
|
+
if (!existsSync(join(root, normalized))) {
|
|
442
|
+
errors.push({
|
|
443
|
+
path: rawPath,
|
|
444
|
+
message: `design_file_ref_invalid:文件变更清单条目「${rawPath}」在${repoLabel}(${root})下不存在且无 NEW: 前缀(幻觉路径/书写错误)——修正路径,或计划新建的文件改为 NEW:${rawPath} 前缀`,
|
|
445
|
+
})
|
|
446
|
+
}
|
|
447
|
+
}
|
|
429
448
|
}
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
449
|
+
|
|
450
|
+
// 坑 design-file-ref-cross-repo-blind(2026-09-15 wp 会话实证,41 条误报):存在性核验
|
|
451
|
+
// 此前恒以主仓 cwd 为根——design §6 按仓分段(D-014)下的跨仓「修改」条目(如
|
|
452
|
+
// sub-grid-security 的 src/common/router.js)在其自己仓里明明存在,却被逼标 NEW: 前缀
|
|
453
|
+
// 过 gate(标「修改」的既有文件被迫声明为计划新建,语义撒谎)。对齐
|
|
454
|
+
// validateDesignFileCoverage 的分段口径:复用 parseDesignCoverageByRepo 按 `## <repo>
|
|
455
|
+
// 仓变更` 段头切分,main 段核主仓根,跨仓段核 local.yaml repos: 注册根;无段头时
|
|
456
|
+
// 整章归 main(原行为零回归)。
|
|
457
|
+
const { byRepo, hasSegmentHeader, malformedHeaders } = parseDesignCoverageByRepo(designPath)
|
|
458
|
+
for (const h of malformedHeaders) {
|
|
459
|
+
warnings.push(`design 清单疑似仓变更段头格式异常(该段文件可能被记到上一段仓,覆盖对账会点名):${h}`)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// 跨仓注册表读侧口径 = <cwd>/.sillyspec/local.yaml(与 execute MultiRepoContext /
|
|
463
|
+
// register-repo 写侧一致,parseRepoRegistry 自带 CRLF 容差)——有段头/无段头两分支
|
|
464
|
+
// 共用(task-05 / FR-10 上移:无段头分支也要做跨仓注册路径前缀命中判定)
|
|
465
|
+
let registry = null
|
|
466
|
+
const localYamlPath = join(cwd, '.sillyspec', 'local.yaml')
|
|
467
|
+
if (existsSync(localYamlPath)) {
|
|
468
|
+
try { registry = parseRepoRegistry(readFileSync(localYamlPath, 'utf8')) } catch { registry = null }
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (!hasSegmentHeader) {
|
|
472
|
+
const entries = parseFileChangeListDetailed(designPath, { keepSillyspecDocs: true })
|
|
473
|
+
if (!Array.isArray(entries) || entries.length === 0) {
|
|
474
|
+
return { ok: true, errors, warnings: [...warnings, 'design 清单核验跳过:design.md 无文件变更清单段(small 变更可无清单)'] }
|
|
437
475
|
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
476
|
+
// task-05 / FR-10 无段头缺口:清单行含跨仓注册路径(注册值/basename 前缀命中)时不按
|
|
477
|
+
// 主仓根打 design_file_ref_invalid 逼 NEW: 前缀(跨仓既有文件被逼声明计划新建=语义撒谎,
|
|
478
|
+
// 坑 design-file-ref-cross-repo-blind 同族的无段头形态),降 warning 提示补段头——补
|
|
479
|
+
// 「## <repo> 仓变更」段头后该段按仓根核验,真幻觉路径仍会在仓根核验下暴露(只消除
|
|
480
|
+
// 假红、不引入假绿)。纯主仓行(无前缀命中)走原主仓根核验,行为零变化。
|
|
481
|
+
const crossPrefixes = []
|
|
482
|
+
if (registry) {
|
|
483
|
+
for (const [repoKey, rootRaw] of registry) {
|
|
484
|
+
const root = String(rootRaw || '').replace(/\\/g, '/').replace(/\/+$/, '')
|
|
485
|
+
if (!root) continue
|
|
486
|
+
const bn = root.split('/').pop()
|
|
487
|
+
crossPrefixes.push({ repoKey, prefixes: bn && bn !== root ? [root, bn] : [root] })
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
const hitCrossRepo = (rawPath) => {
|
|
491
|
+
const n = String(rawPath || '').trim().replace(/\\/g, '/')
|
|
492
|
+
if (!n) return null
|
|
493
|
+
for (const c of crossPrefixes) {
|
|
494
|
+
for (const pre of c.prefixes) {
|
|
495
|
+
if (n === pre || n.startsWith(pre + '/')) return c.repoKey
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return null
|
|
499
|
+
}
|
|
500
|
+
const mainPaths = []
|
|
501
|
+
for (const e of entries) {
|
|
502
|
+
const hit = hitCrossRepo(e.path)
|
|
503
|
+
if (hit) {
|
|
504
|
+
warnings.push(`design 清单无仓变更段头且该行疑似跨仓 ${hit} 仓路径:${e.path}——补「## ${hit} 仓变更」段头后按该仓根核验(暂不按主仓根核验/逼 NEW: 前缀)`)
|
|
505
|
+
} else {
|
|
506
|
+
mainPaths.push(e.path)
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
checkAgainstRoot(cwd, mainPaths, '主仓')
|
|
510
|
+
return { ok: errors.length === 0, errors, warnings }
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// 有段头:逐段按其仓根核验。
|
|
514
|
+
for (const [repo, paths] of byRepo) {
|
|
515
|
+
if (repo === 'main') {
|
|
516
|
+
checkAgainstRoot(cwd, paths, '主仓')
|
|
517
|
+
} else if (registry && registry.has(repo)) {
|
|
518
|
+
checkAgainstRoot(registry.get(repo), paths, `${repo} 仓`)
|
|
519
|
+
} else {
|
|
520
|
+
warnings.push(`design 清单「${repo} 仓变更」段的 repo 未在 local.yaml repos: 注册,跳过该段存在性核验(execute 启动会 fail-closed 拦截,先跑 sillyspec local register-repo ${repo} <仓根路径>)`)
|
|
445
521
|
}
|
|
446
522
|
}
|
|
447
523
|
return { ok: errors.length === 0, errors, warnings }
|
package/src/docs-check.js
CHANGED
|
@@ -117,12 +117,15 @@ export function createHeadReader(projectRoot, ref = 'HEAD') {
|
|
|
117
117
|
* 纯位置锚 `file.js:123?`(2026-09-11 用户实证 docs gate 误伤:跨文件引用+反话论述——锚 A
|
|
118
118
|
* 文件而行内反引号 token 全是 B 概念,层 2 必失败,被迫删行号绕开):`?` 显式声明本锚只做
|
|
119
119
|
* 定位不做关键词断言,层 1(存在性+行界)照校——论述语境锚的一等语法,替代行号化绕开。
|
|
120
|
-
* 文件段展开循环形(D-006,2026-09-08-docs-fix-capability
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
|
|
120
|
+
* 文件段展开循环形(D-006,2026-09-08-docs-fix-capability;圆/方并列 2026-09-17-docs-bracket-reanchor):
|
|
121
|
+
* 普通段可选 + 零或多个「闭合括号段(圆/方同权)+普通段」迭代——Next.js 路由组 (dashboard) 与
|
|
122
|
+
* 动态路由 [id]/[cid] 一等公民(D-001:完整开闭同形括号对并列,[ ] 不进普通段字符类);markdown
|
|
123
|
+
* 链接 [t](foo.js:12) 的 [t] 匹配方括号段后遇 `(` 截止——后续圆括号段要求 `)` 先于 `:` 闭合、
|
|
124
|
+
* 扩展名组要求 `.` 均失败,回溯放弃 [t] 段回落提取 foo.js:12(与圆括号段同族回落,零回归);嵌套
|
|
125
|
+
* ((x)) 与 [[x]]/[[...slug]] 内容含 `(`/`[` 不属括号段字符类 → 段不成立,部分提取残段(与旧正则
|
|
126
|
+
* 逐字节一致)。⚠ 不用原子序列 (?:A+|B+)+——经典嵌套量词 ReDoS(Grill 实证 n=30 长 token 73.8s),
|
|
127
|
+
* 展开循环每次迭代必含完整开闭同形括号对 → 划分唯一 → 线性。 */
|
|
128
|
+
const REF_RE = /(?:repo:\/\/([A-Za-z0-9_.\-]+)\/)?([A-Za-z0-9_.\-\/]*(?:(?:\([A-Za-z0-9_.\-\/]+\)|\[[A-Za-z0-9_.\-\/]+\])[A-Za-z0-9_.\-\/]*)*\.(?:js|mjs|cjs|ts|tsx|jsx|py|java|go)):(\d+)(?:-(\d+))?(\?)?/g
|
|
126
129
|
|
|
127
130
|
/** 缺省扫描范围:docs/ + .sillyspec/docs/(scan/modules 产物同是文档,失效即该暴露;2026-08-16 用户裁决改缺省,见 doc-consistency-debt.md §八)
|
|
128
131
|
* + .sillyspec/changes/ + .sillyspec/knowledge/(P1-1,noai-ir-roadmap §4:最活跃、最易产生
|
|
@@ -167,11 +170,12 @@ export class DocsCheckConfigError extends Error {}
|
|
|
167
170
|
/**
|
|
168
171
|
* 符号锚正则(P1-2,noai-ir-roadmap §4):`file.js::symbol` 形态——校验时解析符号定义行,
|
|
169
172
|
* 天然免疫行号漂移(重锚从「每次代码移动」降为「仅改名时」)。文件段与 REF_RE 同款(含
|
|
170
|
-
* repo://
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
+
* repo:// 跨仓前缀与括号路径组展开;2026-09-17-docs-bracket-reanchor 起圆/方括号段并列
|
|
174
|
+
* 同权——动态路由 app/[lang]/layout.tsx 类路径可符号锚),`::` 后只认 ASCII 标识符(自然语言/
|
|
175
|
+
* 中文括注不触发,QUICKLOG file-notes 的 `path::注` 约定因注解非标识符而天然互斥)。
|
|
176
|
+
* 与 REF_RE 互斥:`::ident` 无数字不匹配行号式;`file.js:12` 无 `::` 不匹配符号式。
|
|
173
177
|
*/
|
|
174
|
-
const SYMBOL_REF_RE = /(?:repo:\/\/([A-Za-z0-9_.\-]+)\/)?([A-Za-z0-9_.\-\/]*(?:\([A-Za-z0-9_.\-\/]+\)[A-Za-z0-9_.\-\/]*)*\.(?:js|mjs|cjs|ts|tsx|jsx|py|java|go))::([A-Za-z_$][A-Za-z0-9_$]*)/g
|
|
178
|
+
const SYMBOL_REF_RE = /(?:repo:\/\/([A-Za-z0-9_.\-]+)\/)?([A-Za-z0-9_.\-\/]*(?:(?:\([A-Za-z0-9_.\-\/]+\)|\[[A-Za-z0-9_.\-\/]+\])[A-Za-z0-9_.\-\/]*)*\.(?:js|mjs|cjs|ts|tsx|jsx|py|java|go))::([A-Za-z_$][A-Za-z0-9_$]*)/g
|
|
175
179
|
|
|
176
180
|
/**
|
|
177
181
|
* 符号定义行定位(与 classifyFix 的 defRe 同款形态,单一判定口径):function/const/let/
|
package/src/docs-debt.js
CHANGED
|
@@ -363,8 +363,13 @@ export function renderDecisionTouchFacts(touches) {
|
|
|
363
363
|
* @returns {{ root: string, changedFiles: string[] }} root = 实际 git 锚(computeDocsDebt 的 projectRoot 同值)
|
|
364
364
|
*/
|
|
365
365
|
export function collectExecuteChangedFiles({ specBase, changeName, cwd, projectRoot } = {}) {
|
|
366
|
-
|
|
367
|
-
|
|
366
|
+
// 双候选 worktree 根(坑 platform-dual-root-fourth-consumer,2026-09-16 hunt 实证):平台模式
|
|
367
|
+
// specBase=specRoot 时主仓 worktree 在项目侧 <cwd>/.sillyspec——单查 specBase 必落空 → root 退
|
|
368
|
+
// cwd → 收进的是主仓脏集而非 worktree 交付集,docs-debt 事实面错。对齐 contract-matrix 双候选。
|
|
369
|
+
const wtCandidates = [...new Set([specBase, join(cwd || process.cwd(), '.sillyspec')].filter(Boolean))]
|
|
370
|
+
.map(b => join(b, '.runtime', 'worktrees', changeName || ''))
|
|
371
|
+
const wtRoot = changeName ? (wtCandidates.find(p => existsSync(p)) || null) : null
|
|
372
|
+
const root = projectRoot || (wtRoot ? wtRoot : cwd) || process.cwd()
|
|
368
373
|
const changed = new Set()
|
|
369
374
|
const statusRes = safeGit(root, ['status', '--porcelain'], { trim: false })
|
|
370
375
|
if (!statusRes.error && statusRes.value) {
|
package/src/docs-gate.js
CHANGED
|
@@ -16,8 +16,16 @@
|
|
|
16
16
|
* 远端 origin/main 已合入的失效消化/新增不会回流本地基线——出现「基线 371 < origin/main
|
|
17
17
|
* 实测 379、本地 current 379」时 ratchet 拦 379>371,但本次推送零增量(不劣于远端)——
|
|
18
18
|
* ratchet 本质=拦增量,被陈旧基线破坏成拦存量。修复:current > baseline 分支先实测
|
|
19
|
-
* origin/main 树(临时 detach worktree 跑 runDocsCheck),current ≤
|
|
20
|
-
*
|
|
19
|
+
* origin/main 树(临时 detach worktree 跑 runDocsCheck),current ≤ 实测值即放行;实测
|
|
20
|
+
* 失败/无远端 ref fail-open 回原拦。快路径(current ≤ baseline)零成本零变化。
|
|
21
|
+
*
|
|
22
|
+
* 陈旧分支自动重锚(2026-09-17-docs-bracket-reanchor,D-002@v1):走到该分支即已实付
|
|
23
|
+
* 远端实测成本验证「本次不劣于远端」——已验证事实自动回流基线:口径守卫通过即
|
|
24
|
+
* writeBaseline(current) + 消息披露(陈旧提示出现一次即消失,棘轮只紧不松:新基线 =
|
|
25
|
+
* current ≤ 远端实测,每一分增量都有实测背书)。守卫只拦 checkOpts 一次性覆盖(paths/
|
|
26
|
+
* skip/keywordAssert/crossRepoRoots 四键——异口径计数写盘会错调基线),不拦 local.yaml
|
|
27
|
+
* 持久口径(measure 与 current 恒同读该配置,读写同源自洽);首次立线 fail-closed 不变
|
|
28
|
+
* (无基线仍须显式 --init-baseline,自动重锚仅限基线已存在的陈旧分支)。
|
|
21
29
|
*/
|
|
22
30
|
import { readFileSync, writeFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
|
|
23
31
|
import { join } from 'node:path';
|
|
@@ -138,12 +146,19 @@ export async function measureRemoteBaselineCount(projectRoot, remoteRefs = ['ori
|
|
|
138
146
|
* IO 入口:跑一次 gate。
|
|
139
147
|
* @param {{ projectRoot: string, specBase: string, initBaseline?: boolean }} opts
|
|
140
148
|
* projectRoot 源码仓根(docs check 锚);specBase .sillyspec 根(基线文件所在)
|
|
141
|
-
* @param {object} checkOpts 透传 runDocsCheck(paths/skip/keywordAssert
|
|
149
|
+
* @param {object} checkOpts 透传 runDocsCheck(paths/skip/keywordAssert/crossRepoRoots 覆盖;缺省
|
|
150
|
+
* 读 local.yaml)——四键任一显式传入即触发陈旧分支口径守卫(不自动重锚,见头注)
|
|
142
151
|
* @returns {Promise<{ exitCode: 0|1|2, ok: boolean, current: number, baseline: number|null,
|
|
143
|
-
* delta: number|null, message: string, inited: boolean
|
|
152
|
+
* delta: number|null, message: string, inited: boolean,
|
|
153
|
+
* reanchored: boolean }>} reanchored:陈旧分支自动重锚 true(baseline 返
|
|
154
|
+
* 新值 = current),其余分支缺省 false
|
|
144
155
|
*/
|
|
145
156
|
export async function runDocsGate(opts = {}, checkOpts = {}) {
|
|
146
157
|
const { projectRoot, specBase, initBaseline = false } = opts;
|
|
158
|
+
// 口径守卫:checkOpts 一次性子集/异口径覆盖时计数口径 ≠ 持久口径,写盘会错调基线——
|
|
159
|
+
// 陈旧分支不自动重锚(against 不算守卫键:与 --init-baseline --against 同写提交树
|
|
160
|
+
// 实测值语义一致,不新增口径)。不拦 local.yaml 持久口径(measure 与 current 恒同读)
|
|
161
|
+
const scopeGuarded = !!(checkOpts.paths || checkOpts.skip || checkOpts.keywordAssert != null || checkOpts.crossRepoRoots);
|
|
147
162
|
let result;
|
|
148
163
|
try {
|
|
149
164
|
const cfg = readDocsCheckConfig(projectRoot);
|
|
@@ -159,7 +174,7 @@ export async function runDocsGate(opts = {}, checkOpts = {}) {
|
|
|
159
174
|
});
|
|
160
175
|
} catch (e) {
|
|
161
176
|
if (e instanceof DocsCheckConfigError) {
|
|
162
|
-
return { exitCode: 2, ok: false, current: null, baseline: null, delta: null, originCount: null, message: `docs gate 配置错误:${e.message}`, inited: false };
|
|
177
|
+
return { exitCode: 2, ok: false, current: null, baseline: null, delta: null, originCount: null, message: `docs gate 配置错误:${e.message}`, inited: false, reanchored: false };
|
|
163
178
|
}
|
|
164
179
|
throw e;
|
|
165
180
|
}
|
|
@@ -167,7 +182,7 @@ export async function runDocsGate(opts = {}, checkOpts = {}) {
|
|
|
167
182
|
|
|
168
183
|
if (initBaseline) {
|
|
169
184
|
writeBaseline(specBase, current);
|
|
170
|
-
return { exitCode: 0, ok: true, current, baseline: current, delta: 0, originCount: null, message: `📌 docs gate: 基线已初始化为当前实测 ${current} 处失效(${baselineDisplay(specBase)})`, inited: true };
|
|
185
|
+
return { exitCode: 0, ok: true, current, baseline: current, delta: 0, originCount: null, message: `📌 docs gate: 基线已初始化为当前实测 ${current} 处失效(${baselineDisplay(specBase)})`, inited: true, reanchored: false };
|
|
171
186
|
}
|
|
172
187
|
|
|
173
188
|
const baseline = readBaseline(specBase);
|
|
@@ -175,27 +190,39 @@ export async function runDocsGate(opts = {}, checkOpts = {}) {
|
|
|
175
190
|
return {
|
|
176
191
|
exitCode: 2, ok: false, current, baseline: null, delta: null, originCount: null,
|
|
177
192
|
message: `❌ docs gate: 无基线文件(${baselineDisplay(specBase)})。首次使用先跑 sillyspec docs gate --init-baseline(以当前实测数立基线,存量既往不咎只拦增量)`,
|
|
178
|
-
inited: false,
|
|
193
|
+
inited: false, reanchored: false,
|
|
179
194
|
};
|
|
180
195
|
}
|
|
181
196
|
if (Number.isNaN(baseline)) {
|
|
182
|
-
return { exitCode: 2, ok: false, current, baseline: null, delta: null, originCount: null, message: `❌ docs gate: 基线文件损坏(非非负整数),手工修正或 --init-baseline 重置`, inited: false };
|
|
197
|
+
return { exitCode: 2, ok: false, current, baseline: null, delta: null, originCount: null, message: `❌ docs gate: 基线文件损坏(非非负整数),手工修正或 --init-baseline 重置`, inited: false, reanchored: false };
|
|
183
198
|
}
|
|
184
199
|
const v = evaluateRatchet({ current, baseline });
|
|
185
200
|
if (v.ok) {
|
|
186
201
|
// 快路径(current ≤ baseline):原路零变化——不触远端实测(零 git 成本零行为漂移)
|
|
187
|
-
return { exitCode: 0, ok: true, current, baseline, delta: v.delta, originCount: null, message: v.message, inited: false };
|
|
202
|
+
return { exitCode: 0, ok: true, current, baseline, delta: v.delta, originCount: null, message: v.message, inited: false, reanchored: false };
|
|
188
203
|
}
|
|
189
204
|
// 坑 docs-gate-stale-baseline(ql-20260915-004):current > baseline 时 origin 实测兜底——
|
|
190
205
|
// ratchet 本质=拦增量;基线是静态快照,远端已合入的失效增长不回流基线会造成「未劣于远端
|
|
191
|
-
// 也被拦」的假拦。实测 origin/main 树:current ≤ 实测值 = 本次不劣于远端 → 放行 +
|
|
192
|
-
//
|
|
206
|
+
// 也被拦」的假拦。实测 origin/main 树:current ≤ 实测值 = 本次不劣于远端 → 放行 + 自动
|
|
207
|
+
// 重锚落盘(D-002@v1;口径守卫命中时不写盘、维持手动重锚建议);无远端 ref / 实测失败
|
|
208
|
+
// fail-open 回原拦;current > 实测值 = 真增量劣于远端 → 拦。
|
|
193
209
|
const measured = await measureRemoteBaselineCount(projectRoot);
|
|
194
210
|
if (measured.originCount !== null && current <= measured.originCount) {
|
|
211
|
+
if (scopeGuarded) {
|
|
212
|
+
// 守卫:checkOpts 一次性覆盖(异口径计数)——基线文件不动,维持手动 --init-baseline 建议文案
|
|
213
|
+
return {
|
|
214
|
+
exitCode: 0, ok: true, current, baseline, delta: v.delta, originCount: measured.originCount,
|
|
215
|
+
message: `⚠️ docs gate: 基线陈旧:基线 ${baseline} < ${measured.ref} 实测 ${measured.originCount},本次 ${current} 处失效未劣于远端不拦——建议 sillyspec docs gate --init-baseline 重锚锁定(以当前实测 ${current} 立线,存量既往不咎只拦增量)`,
|
|
216
|
+
inited: false, reanchored: false,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
// 自动重锚:已实付实测成本验证「本次不劣于远端」——事实回流基线,同态第二次跑走快路径
|
|
220
|
+
//(陈旧提示与远端实测各只发生一次);新基线 = current ≤ 远端实测,棘轮只紧不松
|
|
221
|
+
writeBaseline(specBase, current);
|
|
195
222
|
return {
|
|
196
|
-
exitCode: 0, ok: true, current, baseline, delta: v.delta, originCount: measured.originCount,
|
|
197
|
-
message: `⚠️ docs gate: 基线陈旧:基线 ${baseline} < ${measured.ref} 实测 ${measured.originCount},本次 ${current}
|
|
198
|
-
inited: false,
|
|
223
|
+
exitCode: 0, ok: true, current, baseline: current, delta: v.delta, originCount: measured.originCount,
|
|
224
|
+
message: `⚠️ docs gate: 基线陈旧:基线 ${baseline} < ${measured.ref} 实测 ${measured.originCount},本次 ${current} 处失效未劣于远端不拦——📌 已自动重锚 基线 ${baseline}→${current}(实测不劣于远端,已落盘锁定;棘轮只紧不松)`,
|
|
225
|
+
inited: false, reanchored: true,
|
|
199
226
|
};
|
|
200
227
|
}
|
|
201
228
|
const remoteNote = measured.originCount !== null
|
|
@@ -204,6 +231,6 @@ export async function runDocsGate(opts = {}, checkOpts = {}) {
|
|
|
204
231
|
return {
|
|
205
232
|
exitCode: 1, ok: false, current, baseline, delta: v.delta, originCount: measured.originCount,
|
|
206
233
|
message: `❌ docs gate: ${current} 处失效 > 基线 ${baseline}(新增 ${v.delta} 处)${remoteNote},拦截。修掉新增引用或显式 --init-baseline 重置基线(需你确认存量合法)`,
|
|
207
|
-
inited: false,
|
|
234
|
+
inited: false, reanchored: false,
|
|
208
235
|
};
|
|
209
236
|
}
|
|
@@ -24,6 +24,46 @@ function lineOfIndex(content, index) {
|
|
|
24
24
|
return content.slice(0, index).split('\n').length
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* 注释掩码(坑 endpoint-extractor-selfdoc-noise,2026-09-16 E 变更 verify 实证):JS/Java 的
|
|
29
|
+
* // 与 /* *\/ 注释区替换为等长空格(换行保留)——注释里的 `app.use("/api", router)`、
|
|
30
|
+
* `router.get("/api/xxx")`、`@GetMapping("/x")` 文档示例不再被全文正则当真路由(探针5 曾报
|
|
31
|
+
* 「GET /api/path、GET /api unused」假 advisory)。字符串字面量(' " `)原样保留(真路由在
|
|
32
|
+
* 字符串里);正则字面量按字符串近似处理(端点正则里出现路由字符串形态极罕见,误掩概率低)。
|
|
33
|
+
* 保长度 = 索引/行号零漂移,lineOfIndex 与全部既有正则兼容。
|
|
34
|
+
* @param {string} content
|
|
35
|
+
* @returns {string} 等长掩码后内容
|
|
36
|
+
*/
|
|
37
|
+
function stripCommentsKeepLength(content) {
|
|
38
|
+
const src = String(content || '')
|
|
39
|
+
const out = src.split('')
|
|
40
|
+
let st = 'normal' // normal | block | sq | dq | tick
|
|
41
|
+
for (let i = 0; i < src.length; i++) {
|
|
42
|
+
const c = src[i], n = src[i + 1]
|
|
43
|
+
if (st === 'normal') {
|
|
44
|
+
if (c === '/' && n === '/') {
|
|
45
|
+
// 行注释:掩到行尾(保留换行符本身)
|
|
46
|
+
let j = i
|
|
47
|
+
while (j < src.length && src[j] !== '\n') { out[j] = ' '; j++ }
|
|
48
|
+
i = j - 1
|
|
49
|
+
} else if (c === '/' && n === '*') {
|
|
50
|
+
out[i] = ' '; out[i + 1] = ' '
|
|
51
|
+
st = 'block'; i++
|
|
52
|
+
} else if (c === "'") st = 'sq'
|
|
53
|
+
else if (c === '"') st = 'dq'
|
|
54
|
+
else if (c === '`') st = 'tick'
|
|
55
|
+
} else if (st === 'block') {
|
|
56
|
+
if (c === '*' && n === '/') { out[i] = ' '; out[i + 1] = ' '; st = 'normal'; i++ }
|
|
57
|
+
else if (c !== '\n') out[i] = ' '
|
|
58
|
+
} else {
|
|
59
|
+
// 字符串内:转义跳过;闭合引号回 normal(内容原样保留)
|
|
60
|
+
if (c === '\\') i++
|
|
61
|
+
else if ((st === 'sq' && c === "'") || (st === 'dq' && c === '"') || (st === 'tick' && c === '`')) st = 'normal'
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return out.join('')
|
|
65
|
+
}
|
|
66
|
+
|
|
27
67
|
/**
|
|
28
68
|
* 从单个文件提取 FastAPI router 端点
|
|
29
69
|
* 支持 APIRouter(prefix=...) 和 @router.get/post/put/delete/patch("/path")
|
|
@@ -73,7 +113,13 @@ export function extractFastApiEndpoints(filePath) {
|
|
|
73
113
|
* @returns {Array<{ method: string, path: string, source: string, line: number }>}
|
|
74
114
|
*/
|
|
75
115
|
export function extractExpressEndpoints(filePath) {
|
|
76
|
-
const
|
|
116
|
+
const rawContent = readFileSync(filePath, 'utf8')
|
|
117
|
+
// 注释掩码(坑 endpoint-extractor-selfdoc-noise,2026-09-16 E 变更 verify 实证:本模块自身
|
|
118
|
+
// JSDoc 示例 `app.use("/api", router)` / `router.get("/api/xxx")` 被全文正则当真路由——
|
|
119
|
+
// 探针5 报「GET /api/path、GET /api unused」假 advisory,agent 逐条裁定纯耗损)。掩码保长度
|
|
120
|
+
// 替换注释区为空格(换行保留):字符串字面量不动(真路由在字符串里),索引/行号零漂移,
|
|
121
|
+
// 下游 lineOfIndex/正则全兼容。
|
|
122
|
+
const content = stripCommentsKeepLength(rawContent)
|
|
77
123
|
const lines = content.split('\n')
|
|
78
124
|
const endpoints = []
|
|
79
125
|
|
|
@@ -116,7 +162,9 @@ export function extractExpressEndpoints(filePath) {
|
|
|
116
162
|
* @returns {Array<{ method: string, path: string, source: string, line: number }>}
|
|
117
163
|
*/
|
|
118
164
|
export function extractSpringEndpoints(filePath) {
|
|
119
|
-
|
|
165
|
+
// 注释掩码同 Express(坑 endpoint-extractor-selfdoc-noise;Java 与 JS 注释语法同族 // /* */
|
|
166
|
+
// ——掩码器共用。注解本体不在注释内,掩码零影响;注释里的 @GetMapping 示例不再入面)
|
|
167
|
+
const content = stripCommentsKeepLength(readFileSync(filePath, 'utf8'))
|
|
120
168
|
const lines = content.split('\n')
|
|
121
169
|
const endpoints = []
|
|
122
170
|
|
package/src/foreign-declared.js
CHANGED
|
@@ -45,23 +45,32 @@ function getMainDirtySet(cwd) {
|
|
|
45
45
|
* 变更 worktree 活性速查(.runtime/worktrees/<change>/meta.json)。
|
|
46
46
|
* isolated=true:有存活隔离 worktree(meta 在、非 in-place、worktreePath 目录在)——
|
|
47
47
|
* 其 WIP 在 worktree 内主仓 porcelain 不可见,整份 design 声明按在途处理。
|
|
48
|
-
*
|
|
48
|
+
* 双根扫描(坑 platform-dual-root-fourth-consumer,2026-09-16 hunt 实证):平台模式
|
|
49
|
+
* specBase=specRoot 下只挂跨仓 worktree,主仓 worktree 在项目侧 <cwd>/.sillyspec——单扫
|
|
50
|
+
* specBase 会把主仓 worktree 变更误判 stale → 其声明失效 → 文件被误归属当前变更(③类
|
|
51
|
+
* 噪音/误扣)。specBase 与 cwd 项目侧两目录都扫,按变更名合并(specBase 先入)。
|
|
52
|
+
* @returns {Map<string, { isolated: boolean }>|null} null = 两目录均读取失败(fail-closed)
|
|
49
53
|
*/
|
|
50
|
-
function loadWorktreeLiveness(specBase) {
|
|
54
|
+
export function loadWorktreeLiveness(specBase, cwd) {
|
|
51
55
|
const map = new Map()
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
56
|
+
const roots = [...new Set([specBase, join(cwd || process.cwd(), '.sillyspec')].filter(Boolean))]
|
|
57
|
+
let anyDirOk = false
|
|
58
|
+
for (const base of roots) {
|
|
59
|
+
try {
|
|
60
|
+
const dir = join(base, '.runtime', 'worktrees')
|
|
61
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
62
|
+
if (!e.isDirectory()) continue
|
|
63
|
+
try {
|
|
64
|
+
const meta = JSON.parse(readFileSync(join(dir, e.name, 'meta.json'), 'utf8'))
|
|
65
|
+
const isolated = Boolean(meta && meta.mode !== 'in-place-fallback'
|
|
66
|
+
&& meta.worktreePath && existsSync(meta.worktreePath))
|
|
67
|
+
map.set(meta.changeName || e.name, { isolated })
|
|
68
|
+
} catch { /* 单条损坏跳过(该变更走主仓 dirty 判定) */ }
|
|
69
|
+
}
|
|
70
|
+
anyDirOk = true
|
|
71
|
+
} catch { /* 该根不存在/不可读 → 试下一根 */ }
|
|
72
|
+
}
|
|
73
|
+
return anyDirOk ? map : null
|
|
65
74
|
}
|
|
66
75
|
|
|
67
76
|
/**
|
|
@@ -74,7 +83,7 @@ function loadWorktreeLiveness(specBase) {
|
|
|
74
83
|
function filterStaleForeignDeclarations(foreign, cwd, specBase) {
|
|
75
84
|
if (foreign.size === 0) return
|
|
76
85
|
const dirty = getMainDirtySet(cwd)
|
|
77
|
-
const worktrees = loadWorktreeLiveness(specBase)
|
|
86
|
+
const worktrees = loadWorktreeLiveness(specBase, cwd)
|
|
78
87
|
if (dirty === null && worktrees === null) return // 两个事实源全失败 → 原样保留
|
|
79
88
|
for (const [file, owners] of [...foreign.entries()]) {
|
|
80
89
|
const live = owners.filter(owner => {
|
|
@@ -308,6 +308,8 @@ export {
|
|
|
308
308
|
matchReadonlyWhitelist as _matchReadonlyWhitelistForTest,
|
|
309
309
|
matchDangerBlacklist as _matchDangerBlacklistForTest,
|
|
310
310
|
isSingleCommandReadonly as _isSingleCommandReadonlyForTest,
|
|
311
|
+
analyzeCrossRepoCd as _analyzeCrossRepoCdForTest,
|
|
312
|
+
CROSS_REPO_TEST_RE as _CROSS_REPO_TEST_REForTest,
|
|
311
313
|
};
|
|
312
314
|
/**
|
|
313
315
|
* 从 sillyspec.db 读取 currentStage(直读 DB,task-10 废 gate-status.json 后为唯一来源)
|
|
@@ -834,6 +836,56 @@ export function shouldBlockWrite(filePath, cwd) {
|
|
|
834
836
|
}
|
|
835
837
|
}
|
|
836
838
|
|
|
839
|
+
/**
|
|
840
|
+
* 跨仓命令锚点感知(known-issues 2026-09-16 登记「worktree 隔离期跨仓命令锚定错位」兑现):
|
|
841
|
+
* EHS 三仓形态(主仓 + repos 注册兄弟仓)两处摩擦——①worktree 内 `cd ../<注册仓>` 解析到
|
|
842
|
+
* `.sillyspec/.runtime/worktrees/` 存储目录而非真实兄弟仓(锚点错位,静默跑错地方);②
|
|
843
|
+
* verify 等非 execute/quick 阶段从主仓跑 `cd ../注册仓 && npx eslint` 属合法只读跨仓操作,
|
|
844
|
+
* 但 cd 不在只读白名单 → 整条进 stage 门禁拦截。
|
|
845
|
+
* 处置:①worktree cwd 本就全放行——不拦,只 stderr 纠偏留痕(真实兄弟仓路径);②从严放行
|
|
846
|
+
* (cd 目标命中 repos 注册根 + 其余片段全部测试/lint 类 + 危险黑名单不沾),写类/混合维持
|
|
847
|
+
* 原判 fail-closed。
|
|
848
|
+
*/
|
|
849
|
+
const CROSS_REPO_TEST_RE = /^(?:npm (?:test|run test[\w:.-]*)|npx (?:eslint|tsc|jest|vitest|mocha|stylelint|prettier)(?:\s|$)|node --test)/i
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* 解析复合命令中的 cd 目标并比对 repos 注册仓根。
|
|
853
|
+
* @param {string} command 原始命令
|
|
854
|
+
* @param {string} callerCwd 调用方 cwd(相对 cd 目标的解析基准之一)
|
|
855
|
+
* @param {string} projectRoot 主仓根(worktree 形态下的正确锚点基准)
|
|
856
|
+
* @param {boolean} inWorktree 调用方是否在 worktree 内(决定 ../ 的解析基准)
|
|
857
|
+
* @returns {{hits: Array<{key:string,repoRoot:string,resolved:string,target:string}>, segments: string[]}|null}
|
|
858
|
+
* 无 cd 段 / 无 repos 注册 / 无命中 → null
|
|
859
|
+
*/
|
|
860
|
+
function analyzeCrossRepoCd(command, callerCwd, projectRoot, inWorktree) {
|
|
861
|
+
const segments = splitCommandParts(command)
|
|
862
|
+
const cdTargets = []
|
|
863
|
+
for (const seg of segments) {
|
|
864
|
+
const m = seg.match(/^cd\s+([^\s&|;]+)/i)
|
|
865
|
+
if (m) cdTargets.push(m[1])
|
|
866
|
+
}
|
|
867
|
+
if (cdTargets.length === 0) return null
|
|
868
|
+
const repos = loadLocalConfig(projectRoot).repos
|
|
869
|
+
if (!repos || typeof repos !== 'object') return null
|
|
870
|
+
const hits = []
|
|
871
|
+
for (const t of cdTargets) {
|
|
872
|
+
// 双基准:shell 实际解析(callerCwd——worktree 内 ../ 即存储目录错位形态)与意图解析
|
|
873
|
+
// (worktree 形态按主仓根、主仓 cwd 即 shell 本身)。注册命中按意图基准;纠偏提示按
|
|
874
|
+
// 「shell 解析落在 worktree 存储目录」判定。
|
|
875
|
+
const shellResolved = path.isAbsolute(t) ? t : path.resolve(callerCwd, t)
|
|
876
|
+
const intended = path.isAbsolute(t) ? t : path.resolve(inWorktree ? projectRoot : callerCwd, t)
|
|
877
|
+
for (const [key, raw] of Object.entries(repos)) {
|
|
878
|
+
if (typeof raw !== 'string' || !raw) continue
|
|
879
|
+
const repoRoot = path.isAbsolute(raw) ? raw : path.resolve(projectRoot, raw)
|
|
880
|
+
if (intended === repoRoot || isPathInside(intended, repoRoot)) {
|
|
881
|
+
hits.push({ key, repoRoot, resolved: intended, shellResolved, target: t })
|
|
882
|
+
break
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
return hits.length > 0 ? { hits, segments } : null
|
|
887
|
+
}
|
|
888
|
+
|
|
837
889
|
/**
|
|
838
890
|
* 判断 Bash 命令是否应被拦截
|
|
839
891
|
* @param {string} command - Bash 命令字符串
|
|
@@ -846,8 +898,19 @@ function shouldBlockBash(command, cwd) {
|
|
|
846
898
|
const callerCwd = cwd || process.cwd()
|
|
847
899
|
const projectRoot = findProjectRoot(callerCwd)
|
|
848
900
|
|
|
849
|
-
// cwd 在 worktree 内 →
|
|
850
|
-
|
|
901
|
+
// cwd 在 worktree 内 → 全部放行(跨仓锚点纠偏留痕:../注册仓 在 worktree 内解析到
|
|
902
|
+
// worktrees 存储目录——不拦只纠正,known-issues③)
|
|
903
|
+
if (isInsideRegisteredWorktree(callerCwd, projectRoot)) {
|
|
904
|
+
try {
|
|
905
|
+
const xr = analyzeCrossRepoCd(command, callerCwd, projectRoot, true)
|
|
906
|
+
for (const h of (xr && xr.hits) || []) {
|
|
907
|
+
if (isInsideWorktreeStorage(h.shellResolved, projectRoot)) {
|
|
908
|
+
console.error(`[sillyspec hook] ℹ️ 跨仓锚点纠偏:cd ${h.target} 在 worktree 内实际会解析到 ${h.shellResolved}(worktrees 存储目录,非真实兄弟仓)。注册仓 ${h.key} 的真实路径:${h.repoRoot}——跨仓测试/构建请改用真实路径。`)
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
} catch { /* 纠偏 fail-soft:解析异常不影响放行 */ }
|
|
912
|
+
return { blocked: false }
|
|
913
|
+
}
|
|
851
914
|
|
|
852
915
|
// 阶段门禁(直读 sillyspec.db)
|
|
853
916
|
const stage = readCurrentStage(projectRoot) || '(none)'
|
|
@@ -857,6 +920,16 @@ function shouldBlockBash(command, cwd) {
|
|
|
857
920
|
const localConfig = loadLocalConfig(projectRoot)
|
|
858
921
|
const extraReadonly = localConfig.worktreeHook?.readonlyCommands || localConfig['worktree-hook']?.readonlyCommands || []
|
|
859
922
|
if (matchReadonlyWhitelist(command, extraReadonly)) return { blocked: false }
|
|
923
|
+
// 跨仓注册仓测试类放行(known-issues③):verify 等阶段从主仓跑兄弟仓测试/lint 是合法
|
|
924
|
+
// 只读操作。从严三条件:cd 目标命中 repos 注册根 + 其余片段全部测试/lint 类 + 危险
|
|
925
|
+
// 黑名单不沾——写类/混合命令维持 stage 门禁原判(fail-closed)。
|
|
926
|
+
if (!matchDangerBlacklist(command)) {
|
|
927
|
+
const xr = analyzeCrossRepoCd(command, callerCwd, projectRoot, false)
|
|
928
|
+
if (xr && xr.segments.every(seg => /^cd\s+/i.test(seg) || CROSS_REPO_TEST_RE.test(seg))) {
|
|
929
|
+
console.error(`[sillyspec hook] 放行依据 = 跨仓注册仓(${xr.hits.map(h => h.key).join('、')})测试/lint 类命令(known-issues③ 锚点感知,其余片段全测试类+黑名单不沾)`)
|
|
930
|
+
return { blocked: false }
|
|
931
|
+
}
|
|
932
|
+
}
|
|
860
933
|
return {
|
|
861
934
|
blocked: true,
|
|
862
935
|
reason: buildStageHint(stage)
|