sillyspec 3.28.10 → 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-risk-profile.js +51 -3
- package/src/config-schema.js +21 -0
- package/src/design-facts.js +46 -9
- 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 +129 -1
- package/src/machine-interface.js +26 -0
- package/src/probe7-anchor-check.js +20 -2
- package/src/progress/stage-machine.js +34 -5
- package/src/progress.js +1 -1
- package/src/quicklog.js +231 -1
- package/src/run/complete-handlers.js +172 -1
- package/src/run/complete.js +73 -3
- package/src/run/gate-snapshot.js +262 -0
- package/src/run/gates.js +41 -7
- package/src/run/prompt.js +79 -1
- package/src/run/shared.js +10 -0
- 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/execute.js +62 -8
- package/src/stages/plan-postcheck.js +42 -5
- package/src/stages/plan.js +8 -1
- package/src/stages/quick.js +5 -5
- package/src/stages/verify.js +3 -2
- package/src/sync.js +69 -0
- package/src/task-review.js +9 -3
- package/src/verify-facts-schema.js +78 -9
- package/src/verify-postcheck.js +260 -26
- package/src/verify-probes.js +780 -44
- package/src/worktree-apply.js +175 -3
- package/src/write-audit.js +55 -0
- package/.claude/full.json +0 -1
|
@@ -301,6 +301,33 @@ export function detectChangeRisk({ designContent = '', planContent = '', changed
|
|
|
301
301
|
return { level, triggers, suppressedTriggers, requiredVerification }
|
|
302
302
|
}
|
|
303
303
|
|
|
304
|
+
// ============ 回执来源分类打标(X-10 / D-002 / D-006,2026-09-17-pass-cap-semantics task-02) ============
|
|
305
|
+
//
|
|
306
|
+
// EHS 实证(D-002):mvn compile + JUnitCore 纯单测的三条 log 通过了集成回执四条件校验——
|
|
307
|
+
// 单测/编译类回执被当成集成实测。打标口径:只认回执 command 的命令来源声明(quality-scan
|
|
308
|
+
// 记录的命令与 verify_precedents 声明同思想),不解析日志内容猜测(X-10);未定类默认
|
|
309
|
+
// 'build'(fail-closed 侧:build/unit 回执不构成「集成实测已跑」绿判据,宁可触发封顶要求
|
|
310
|
+
// handover——stage-contract 侧 validatePassEligibility(evaluatePassEligibility 纯函数 +
|
|
311
|
+
// resolveFactsExpected 判定)消费本口径对结论=PASS 封顶;verify-probes producer 侧
|
|
312
|
+
// classifyReceiptCommandSource(facts.integrationRan 判定)与本分类同族口径,两处正则族
|
|
313
|
+
// 保持同步演进)。smoke 冒烟命令族计 cross-layer(D-006 退役判据:批次 C commands.smoke
|
|
314
|
+
// 落地后由 smoke 回执一票判定「集成实测已跑」——起服务冒烟即跨层实测,不得默认 build 误拦金路径)。
|
|
315
|
+
const RECEIPT_SOURCE_CROSS_LAYER_RE = new RegExp([
|
|
316
|
+
'\\bcurl\\b', '\\bwget\\b', '\\bhttpie\\b', '\\bInvoke-WebRequest\\b', '\\bInvoke-RestMethod\\b',
|
|
317
|
+
'\\biwr\\b', '\\birm\\b', 'https?://',
|
|
318
|
+
'spring-boot:run', '\\bjava\\b[^|\\n]*\\s-jar\\b',
|
|
319
|
+
'\\b(?:npm|pnpm|yarn|bun)\\s+(?:run\\s+)?(?:dev|start|serve|smoke)\\b',
|
|
320
|
+
'\\bdotnet\\s+run\\b', '\\bflask\\s+run\\b', '\\buvicorn\\b', '\\bgunicorn\\b',
|
|
321
|
+
'\\b(?:nc|netcat|telnet|socat)\\b',
|
|
322
|
+
].join('|'), 'i')
|
|
323
|
+
const RECEIPT_SOURCE_UNIT_RE = /\bJUnitCore\b|\bnode\s+--test\b|\bmocha\b|\bjest\b|\bvitest\b|\bpytest\b|\bphpunit\b|\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?test\b/i
|
|
324
|
+
function classifyReceiptSourceTag(command) {
|
|
325
|
+
const cmd = String(command || '')
|
|
326
|
+
if (RECEIPT_SOURCE_CROSS_LAYER_RE.test(cmd)) return 'cross-layer'
|
|
327
|
+
if (RECEIPT_SOURCE_UNIT_RE.test(cmd)) return 'unit'
|
|
328
|
+
return 'build'
|
|
329
|
+
}
|
|
330
|
+
|
|
304
331
|
/**
|
|
305
332
|
* 检查 verify-result.md 是否包含集成验证证据
|
|
306
333
|
*
|
|
@@ -308,6 +335,10 @@ export function detectChangeRisk({ designContent = '', planContent = '', changed
|
|
|
308
335
|
* CLI 结构化回执文本(如 verify-services.receipt.json 的服务回收回执)——它随 verifyContent
|
|
309
336
|
* 一起参与 literals 匹配,agent 真实起过服务且 CLI 回收过(有回执)时不再依赖其自然语言
|
|
310
337
|
* 措辞恰好含字面词,表述差异不再误拦。
|
|
338
|
+
*
|
|
339
|
+
* opts.sourceTag(X-10 / D-006,2026-09-17-pass-cap-semantics task-02):可选回执来源声明
|
|
340
|
+
* ('cross-layer' | 'build' | 'unit'),透传给 auditRuntimeReceipt 优先于按 command 就地
|
|
341
|
+
* 分类;非法值忽略。build/unit 回执不作集成实测绿判据(缺省按 command 分类,未定类 build)。
|
|
311
342
|
*/
|
|
312
343
|
export function checkIntegrationEvidence(verifyContent, requiredVerification, opts = {}) {
|
|
313
344
|
const errors = []
|
|
@@ -333,7 +364,7 @@ export function checkIntegrationEvidence(verifyContent, requiredVerification, op
|
|
|
333
364
|
const hasGreen = greenReceipts.length > 0
|
|
334
365
|
if (needsIntegration && !hasGreen) {
|
|
335
366
|
const bad = receiptAudit.filter(a => !a.green)
|
|
336
|
-
errors.push(`integration-critical 变更无绿回执(${bad.length} 条回执校验不过:${bad.map(a => a.reason).join(';') || '无有效回执'})——绿判据:log 存在 × mtime ∈ verify 窗口 × 日志无失败签名 × exit 0
|
|
367
|
+
errors.push(`integration-critical 变更无绿回执(${bad.length} 条回执校验不过:${bad.map(a => a.reason).join(';') || '无有效回执'})——绿判据:log 存在 × mtime ∈ verify 窗口 × 日志无失败签名 × exit 0 × 来源 cross-layer(build/unit 回执不作集成实测判据,D-006)`)
|
|
337
368
|
}
|
|
338
369
|
if (needsLogEvidence && !hasGreen) {
|
|
339
370
|
errors.push('integration-critical 变更的回执未通过一致性校验(Runtime Evidence 等价物)')
|
|
@@ -394,9 +425,19 @@ export function checkIntegrationEvidence(verifyContent, requiredVerification, op
|
|
|
394
425
|
* 单条回执一致性校验(FR-04 绿判据)。噪声剔除:签名行首匹配 + 剔除「0 errors」类良性行
|
|
395
426
|
* (本仓 verify-postcheck.js 测试输出解析先例同款)。fail-soft:校验依赖缺失(opts 无
|
|
396
427
|
* cwd/verifyStartAt)时只核 exitCode 与日志可读性,不假装跑了文件校验。
|
|
428
|
+
*
|
|
429
|
+
* opts.sourceTag(X-10 / D-006,2026-09-17-pass-cap-semantics task-02):可选回执来源声明,
|
|
430
|
+
* 缺省按回执 command 来源就地分类(classifyReceiptSourceTag),未定类默认 'build'(向后
|
|
431
|
+
* 兼容的 fail-closed 缺省);sourceTag ∈ {build, unit} 的回执不作集成实测绿判据(四条件
|
|
432
|
+
* 全过也只到 non-green,reason 点名来源)——verify-postcheck 不在本调用链上,不受影响。
|
|
397
433
|
*/
|
|
398
434
|
function auditRuntimeReceipt(r, opts) {
|
|
399
|
-
const out = { claim: r && r.claim, logPath: r && r.logPath, green: false, logExists: null, mtimeInWindow: null, failSignatures: 0, exitCode: r && typeof r.exitCode === 'number' ? r.exitCode : null, reason: '' }
|
|
435
|
+
const out = { claim: r && r.claim, logPath: r && r.logPath, sourceTag: null, green: false, logExists: null, mtimeInWindow: null, failSignatures: 0, exitCode: r && typeof r.exitCode === 'number' ? r.exitCode : null, reason: '' }
|
|
436
|
+
// 打标依据 = 命令来源声明(不解析日志内容,X-10):opts.sourceTag 调用方声明源优先,
|
|
437
|
+
// 否则按回执 command 分类;非法/缺省走分类,未定类默认 build。
|
|
438
|
+
const declaredSource = opts && typeof opts.sourceTag === 'string'
|
|
439
|
+
&& ['cross-layer', 'build', 'unit'].includes(opts.sourceTag) ? opts.sourceTag : null
|
|
440
|
+
out.sourceTag = declaredSource || classifyReceiptSourceTag(r && r.command)
|
|
400
441
|
if (!r || !r.logPath) { out.reason = '回执缺 logPath'; return out }
|
|
401
442
|
let content = null
|
|
402
443
|
try {
|
|
@@ -424,7 +465,14 @@ function auditRuntimeReceipt(r, opts) {
|
|
|
424
465
|
}
|
|
425
466
|
if (out.failSignatures > 0) { out.reason = `日志含 ${out.failSignatures} 行失败签名(行首 error/exception/traceback/fatal,已剔除良性计数行)`; return out }
|
|
426
467
|
if (out.exitCode !== 0) { out.reason = `exitCode=${out.exitCode}(非 0 不可用作在场证据)`; return out }
|
|
468
|
+
// 来源门(D-002/D-006,2026-09-17-pass-cap-semantics task-02):四条件全过但来源 build/unit
|
|
469
|
+
// (编译/单测/构建类命令)→ 不作集成实测绿判据(EHS 实证口径),排在前置硬伤(缺日志/
|
|
470
|
+
// 出窗/签名/exit≠0)之后不抢 reason 优先级。
|
|
471
|
+
if (out.sourceTag !== 'cross-layer') {
|
|
472
|
+
out.reason = `回执来源 ${out.sourceTag}(command:${(r && r.command) || ''})不构成「集成实测已跑」——build/unit 类命令(编译/单测/构建)不作集成绿判据(D-006)。出路:提供跨层实测回执(起服务/HTTP/进程对进程/smoke 冒烟命令),或降级 PASS WITH NOTES 并在「## 移交项(结构化)」承载缺口`
|
|
473
|
+
return out
|
|
474
|
+
}
|
|
427
475
|
out.green = true
|
|
428
|
-
out.reason = '绿回执(log 存在 × mtime 窗口 × 无失败签名 × exit 0)'
|
|
476
|
+
out.reason = '绿回执(log 存在 × mtime 窗口 × 无失败签名 × exit 0 × 来源 cross-layer)'
|
|
429
477
|
return out
|
|
430
478
|
}
|
package/src/config-schema.js
CHANGED
|
@@ -152,6 +152,15 @@ export const LOCAL_YAML_SCHEMA = {
|
|
|
152
152
|
{ path: 'worktree.supplyFiles', type: 'array', optional: true, status: 'live', readers: ['readSupplyFilesConfig + _supplyGeneratedFiles (src/worktree.js — create step 5.9 供给步)'], desc: '随 worktree create 从主仓复制的生成物清单:精确路径或 glob(* 单层 / ** 多层),相对仓根。展开上限 200 文件超出截断警告;实供清单记 meta.supplyFiles;缺省 [] = 供给步空转零行为变化。', example: 'src/build-id.ts' },
|
|
153
153
|
],
|
|
154
154
|
},
|
|
155
|
+
{
|
|
156
|
+
id: 'gate_snapshot',
|
|
157
|
+
title: '门禁隔离快照 copy 面',
|
|
158
|
+
note: '门禁快照(gate-snapshot)= HEAD worktree + 会话文件 overlay + 环境目录 junction——gitignored 生成物(api-types/generated 类)不进 HEAD 也不在会话集,快照内 lint/test 环境性假败(2026-09-16 驾驭小结④,friction5-hardening R4 / D-002@v1)。按本清单在快照构建期从主仓 junction 链接补齐(失败回退复制);快照内 overlay 已覆盖的同名路径跳过(本变更最新态优先)。⚠️ junction 是活链接:快照内再跑生成命令会写穿到主仓该目录。',
|
|
159
|
+
keys: [
|
|
160
|
+
{ path: 'gate_snapshot.copy', type: 'array', optional: true, status: 'live', readers: ['createGateSnapshot (src/run/gate-snapshot.js)'], desc: '门禁隔离快照的生成物/额外路径 copy 面(元素 string,相对仓根):HEAD 快照缺 gitignored 生成物导致 lint/test 环境性假败;声明后快照构建期从主仓 junction 链接(失败回退复制)。⚠️ junction 是活链接,快照内再跑生成命令会写穿到主仓该目录。未配置/空清单 = 全段空转零行为变化。', example: 'gate_snapshot:\n copy:\n - src/generated' },
|
|
161
|
+
{ path: 'gate_snapshot.commands', type: 'array', optional: true, status: 'live', readers: ['parseGateSnapshotCommands + runGateSnapshotCommands (src/run/gate-snapshot.js — createGateSnapshot 环境预检后 copy 面前)'], desc: '门禁隔离快照的命令面(元素 string):快照构建期在环境目录链接后、copy 面前于快照根逐条执行(cwd=快照根、300s/条超时帽、非零退出/超时只 warn 继续——fail-open 不作废快照),产出「本仓应然态」生成物,收口主仓生成物缺失/过期(fresh clone 未跑 postinstall / build-id 类产物)时快照内全量 lint/test 必挂的坑(D-002@v2,2026-09-17-feedback-hardening FR-01)。⚠️ 环境目录(node_modules/venv)是活链接,命令写它们会穿透主仓——只放生成物命令(npm run gen:build-id 类),不放 install/build 全家桶。未配置 = 全段空转零行为。', example: 'npm run gen:build-id' },
|
|
162
|
+
],
|
|
163
|
+
},
|
|
155
164
|
{
|
|
156
165
|
id: 'auto_mode',
|
|
157
166
|
title: '变更规模自动分类',
|
|
@@ -379,6 +388,18 @@ worktree-hook:
|
|
|
379
388
|
# supplyFiles:
|
|
380
389
|
# - src/build-id.ts
|
|
381
390
|
|
|
391
|
+
# ── 门禁隔离快照 copy 面(HEAD 快照缺 gitignored 生成物 → 快照内 lint/test 环境性假败;构建期从主仓 junction 补齐)──
|
|
392
|
+
# 相对仓根路径列表;失败回退复制;快照内 overlay 已覆盖的同名路径跳过。
|
|
393
|
+
# ⚠️ junction 是活链接:快照内再跑生成命令会写穿到主仓该目录。
|
|
394
|
+
# commands 面(D-002@v2):快照构建期在快照根逐条执行的生成物命令(postinstall/build-id 类,
|
|
395
|
+
# 产出本仓应然态;300s/条超时帽,失败 warn 继续)。⚠️ 命令不得改写 node_modules/venv 等
|
|
396
|
+
# 环境目录(活链接,写穿透主仓)——只放生成物命令。
|
|
397
|
+
# gate_snapshot:
|
|
398
|
+
# copy:
|
|
399
|
+
# - src/generated
|
|
400
|
+
# commands:
|
|
401
|
+
# - npm run gen:build-id
|
|
402
|
+
|
|
382
403
|
# ── 变更规模自动分类(sillyspec run auto 时按需求描述强制 quick/full;正则数组,i 大小写无关)──
|
|
383
404
|
auto_mode:
|
|
384
405
|
force_full_patterns:
|
package/src/design-facts.js
CHANGED
|
@@ -332,7 +332,8 @@ export function generateDesignSkeleton({ changeName, decisionsText, author, now
|
|
|
332
332
|
lines.push('')
|
|
333
333
|
lines.push('| 操作 | 文件路径 | 说明 |')
|
|
334
334
|
lines.push('|---|---|---|')
|
|
335
|
-
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 用户反馈⑥-①,写作期即提示) -->')
|
|
336
337
|
lines.push('')
|
|
337
338
|
|
|
338
339
|
lines.push('## 接口定义')
|
|
@@ -458,22 +459,58 @@ export function validateDesignFileList({ changeDir, cwd } = {}) {
|
|
|
458
459
|
warnings.push(`design 清单疑似仓变更段头格式异常(该段文件可能被记到上一段仓,覆盖对账会点名):${h}`)
|
|
459
460
|
}
|
|
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
|
+
|
|
461
471
|
if (!hasSegmentHeader) {
|
|
462
472
|
const entries = parseFileChangeListDetailed(designPath, { keepSillyspecDocs: true })
|
|
463
473
|
if (!Array.isArray(entries) || entries.length === 0) {
|
|
464
474
|
return { ok: true, errors, warnings: [...warnings, 'design 清单核验跳过:design.md 无文件变更清单段(small 变更可无清单)'] }
|
|
465
475
|
}
|
|
466
|
-
|
|
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, '主仓')
|
|
467
510
|
return { ok: errors.length === 0, errors, warnings }
|
|
468
511
|
}
|
|
469
512
|
|
|
470
|
-
//
|
|
471
|
-
// (与 execute MultiRepoContext / register-repo 写侧一致,parseRepoRegistry 自带 CRLF 容差)
|
|
472
|
-
let registry = null
|
|
473
|
-
const localYamlPath = join(cwd, '.sillyspec', 'local.yaml')
|
|
474
|
-
if (existsSync(localYamlPath)) {
|
|
475
|
-
try { registry = parseRepoRegistry(readFileSync(localYamlPath, 'utf8')) } catch { registry = null }
|
|
476
|
-
}
|
|
513
|
+
// 有段头:逐段按其仓根核验。
|
|
477
514
|
for (const [repo, paths] of byRepo) {
|
|
478
515
|
if (repo === 'main') {
|
|
479
516
|
checkAgainstRoot(cwd, paths, '主仓')
|
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)
|