sillyspec 3.28.7 → 3.28.9
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-quick/SKILL.md +2 -0
- package/.husky/pre-push +3 -1
- package/AGENTS.md +3 -2
- package/README.md +35 -11
- package/SKILL.md +24 -10
- package/SillySpec-/350/203/275/345/212/233/344/272/256/347/202/271/345/205/250/346/231/257-2026-09-14.pptx +0 -0
- package/package.json +1 -1
- package/src/agent-session-log.js +446 -35
- package/src/change-risk-profile.js +20 -0
- package/src/config-cat.js +6 -3
- package/src/config-schema.js +32 -0
- package/src/constants.js +3 -0
- package/src/db.js +23 -6
- package/src/decision-distill.js +15 -3
- package/src/design-facts.js +33 -3
- package/src/docs-check.js +137 -12
- package/src/docs-gate.js +108 -6
- package/src/doctor-diagnostics.js +243 -9
- package/src/friction-ledger.js +171 -0
- package/src/friction-tally.js +7 -2
- package/src/git-helper.js +6 -4
- package/src/hooks/worktree-guard.js +72 -30
- package/src/index.js +356 -81
- package/src/knowledge-classify.js +363 -0
- package/src/knowledge-hits.js +74 -0
- package/src/knowledge-stats.js +180 -0
- package/src/local-detect.js +30 -15
- package/src/local-register.js +13 -4
- package/src/mcp-server.js +38 -8
- package/src/progress/change-registry.js +224 -43
- package/src/progress/consistency-doctor.js +11 -4
- package/src/progress/shared.js +3 -2
- package/src/progress/stage-machine.js +3 -1
- package/src/progress/step-store.js +30 -17
- package/src/progress.js +123 -8
- package/src/quick-gate-profile.js +251 -0
- package/src/quicklog.js +199 -32
- package/src/review-dispatch.js +2 -1
- package/src/run/command.js +124 -15
- package/src/run/complete-handlers.js +514 -24
- package/src/run/complete.js +82 -25
- package/src/run/concurrent-detect.js +99 -1
- package/src/run/gate-snapshot.js +115 -5
- package/src/run/gates.js +85 -14
- package/src/run/prompt.js +143 -6
- package/src/run/quick-audit.js +136 -15
- package/src/run/shared.js +174 -20
- package/src/run/stage.js +6 -0
- package/src/run/verify-quality-scan.js +14 -3
- package/src/scan-diff.js +61 -30
- package/src/scan-postcheck.js +74 -0
- package/src/scan-refresh.js +477 -0
- package/src/scan-staleness.js +25 -13
- package/src/scope-audit.js +365 -35
- package/src/spec-sync.js +19 -14
- package/src/stage-contract-spec.js +4 -3
- package/src/stage-contract.js +185 -2
- package/src/stage-review.js +29 -12
- package/src/stages/brainstorm.js +7 -1
- package/src/stages/execute.js +91 -4
- package/src/stages/knowledge.js +52 -3
- package/src/stages/plan-postcheck.js +57 -12
- package/src/stages/quick.js +1 -1
- package/src/stages/verify.js +1 -0
- package/src/sync-noise.js +114 -6
- package/src/sync.js +201 -11
- package/src/task-review.js +333 -27
- package/src/verify-facts-schema.js +10 -3
- package/src/verify-postcheck.js +41 -6
- package/src/verify-probes.js +348 -7
- package/src/worktree-apply.js +594 -62
- package/src/worktree-cross.js +12 -18
- package/src/worktree-deps.js +100 -2
- package/src/worktree.js +255 -30
- package/src/wt-commit.js +128 -0
- package/templates/agents-instruction.md +2 -2
- package/templates/prompts/testcase-design.md +2 -1
- package/templates/prompts/verify-probes.md +4 -1
- package/sa-rename-probe/src/new.js +0 -4
package/src/constants.js
CHANGED
|
@@ -43,6 +43,9 @@ export function isPointerStale(pointer) {
|
|
|
43
43
|
if (!pointer.completedAt) return false
|
|
44
44
|
const completed = new Date(pointer.completedAt)
|
|
45
45
|
const age = Date.now() - completed.getTime()
|
|
46
|
+
// 非法时间串得 NaN,NaN > x 恒 false——损坏指针永不被标 STALE(2026-09-12 审查批 D-④);
|
|
47
|
+
// 判 STALE 让损坏可见(CORRUPTED 通道只覆盖字段缺失形态)
|
|
48
|
+
if (!Number.isFinite(age)) return true
|
|
46
49
|
return age > 24 * 60 * 60 * 1000
|
|
47
50
|
}
|
|
48
51
|
|
package/src/db.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { openDatabase, applyPragmas, runTransaction } from './db-engine.js';
|
|
2
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'fs';
|
|
2
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'fs';
|
|
3
3
|
import { dirname } from 'path';
|
|
4
4
|
|
|
5
5
|
// DB schema 版本(与 project.schema_version DEFAULT 对齐)。_createSchema 改动(加表/列/migration)时 bump,
|
|
@@ -7,7 +7,7 @@ import { dirname } from 'path';
|
|
|
7
7
|
// 每次 new ProgressManager 都过 init,靠版本戳跳过建表省开销)。
|
|
8
8
|
// node:sqlite(DatabaseSync)是原生 SQLite 引擎,打开即持久化(不像 sql.js 纯内存需整库 export 落盘),
|
|
9
9
|
// _createSchema 内 DDL 直接落盘,无需额外 _save。
|
|
10
|
-
const DB_SCHEMA_VERSION =
|
|
10
|
+
const DB_SCHEMA_VERSION = 6;
|
|
11
11
|
|
|
12
12
|
// SQLITE_BUSY 应用层有限重试(R-08 / NFR-03):WAL 单写者模型,并发写第二者在
|
|
13
13
|
// busy_timeout=5000(init PRAGMA)后抛 SQLITE_BUSY。busy_timeout 已在引擎层处理大部分等待;
|
|
@@ -171,6 +171,12 @@ export class DB {
|
|
|
171
171
|
}
|
|
172
172
|
}
|
|
173
173
|
if (!copied) throw lastErr;
|
|
174
|
+
// SQLite 官方要求:替换 db 文件时必须删除伴生 WAL/SHM——旧 -wal 的 salt 与 db 内容
|
|
175
|
+
// 不绑定,打开恢复库时会对其跑 recovery,把损坏库的脏页回放到 .bak 内容上(再次损坏
|
|
176
|
+
// /混态)。copy 成功后、open 之前清侧车(2026-09-12 审查批 B-①)。
|
|
177
|
+
for (const suffix of ['-wal', '-shm']) {
|
|
178
|
+
try { rmSync(this.dbPath + suffix, { force: true }); } catch { /* 清理失败不阻断:open 若读到坏 WAL 会走损坏兜底 */ }
|
|
179
|
+
}
|
|
174
180
|
return openDatabase(this.dbPath);
|
|
175
181
|
}
|
|
176
182
|
}
|
|
@@ -246,7 +252,7 @@ export class DB {
|
|
|
246
252
|
CREATE TABLE IF NOT EXISTS project (
|
|
247
253
|
id INTEGER PRIMARY KEY DEFAULT 1,
|
|
248
254
|
name TEXT NOT NULL,
|
|
249
|
-
schema_version INTEGER DEFAULT
|
|
255
|
+
schema_version INTEGER DEFAULT 6,
|
|
250
256
|
created_at TEXT NOT NULL,
|
|
251
257
|
updated_at TEXT NOT NULL
|
|
252
258
|
)
|
|
@@ -265,7 +271,8 @@ export class DB {
|
|
|
265
271
|
platform_change_id INTEGER,
|
|
266
272
|
platform_workspace_id INTEGER,
|
|
267
273
|
platform_last_sync TEXT,
|
|
268
|
-
platform_sync_enabled INTEGER DEFAULT 0
|
|
274
|
+
platform_sync_enabled INTEGER DEFAULT 0,
|
|
275
|
+
owner_session TEXT
|
|
269
276
|
)
|
|
270
277
|
`);
|
|
271
278
|
|
|
@@ -364,6 +371,13 @@ export class DB {
|
|
|
364
371
|
// 已语义化,title/quicklog_id 留空。不纳入平台同步脏度(本地展示用元信息)。
|
|
365
372
|
this._migrateAddColumn('changes', 'title', 'TEXT');
|
|
366
373
|
this._migrateAddColumn('changes', 'quicklog_id', 'TEXT');
|
|
374
|
+
|
|
375
|
+
// v6(2026-09-14-change-ownership-guards task-01,D-005@v1):changes 表加 owner_session——
|
|
376
|
+
// change 级所有权载体(接管类操作对他人活跃 change 拒绝执行的心跳窗口判定数据源)。
|
|
377
|
+
// NULL=无主(存量行迁移后即此态,任何会话可接管——向后兼容零行为变化);写入方=
|
|
378
|
+
// change-registry 首建/claimChangeOwner(task-02 接线会话标识);消费方=assertChangeOwnership
|
|
379
|
+
// (task-02)+ serializeForSync 平台同步投影。幂等 ALTER(列存在跳过,v5 先例同款)。
|
|
380
|
+
this._migrateAddColumn('changes', 'owner_session', 'TEXT');
|
|
367
381
|
}
|
|
368
382
|
|
|
369
383
|
/**
|
|
@@ -374,8 +388,11 @@ export class DB {
|
|
|
374
388
|
_migrateAddColumn(table, column, type) {
|
|
375
389
|
try {
|
|
376
390
|
this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
|
|
377
|
-
} catch {
|
|
378
|
-
//
|
|
391
|
+
} catch (e) {
|
|
392
|
+
// 仅「列已存在」可吞(幂等迁移的常态):catch-all 会把 SQLITE_BUSY(并发 init 撞锁)、
|
|
393
|
+
// 磁盘满、未来 DDL 拼写错误一并吞掉——列实际未加却被当已存在,后续 INSERT 报
|
|
394
|
+
// "table has no column",错误现场远离根因(2026-09-12 审查批 B-①)
|
|
395
|
+
if (!/duplicate column name/i.test(String(e && e.message || e))) throw e;
|
|
379
396
|
}
|
|
380
397
|
}
|
|
381
398
|
}
|
package/src/decision-distill.js
CHANGED
|
@@ -80,6 +80,10 @@ function applyField(entry, label, value) {
|
|
|
80
80
|
case '模块域': case '模块': case 'domains': case 'domain': entry.domains = parseListValue(value); break
|
|
81
81
|
// 文件字段(FR-01/D-001@v1,2026-09-11-cross-change-decision-guard):列表语义同模块域 + 逐项反斜杠归一 POSIX
|
|
82
82
|
case '文件': case 'files': entry.files = parseListValue(value).map(s => s.replace(/\\/g, '/')); break
|
|
83
|
+
// 故障面/退役判据(FR-01/D-001@v1,2026-09-15-tax-governance):自维护税治理字段——本决策
|
|
84
|
+
// 引入的新失败模式 / 出现什么信号时简化或删除本机制;单值语义,仅中文标签(模板同源)
|
|
85
|
+
case '故障面': entry.failureMode = value; break
|
|
86
|
+
case '退役判据': entry.retireWhen = value; break
|
|
83
87
|
case '否决理由': case 'reject_reason': case 'rejectreason': entry.rejectReason = value; break
|
|
84
88
|
case '复潮条件': case 'revisit_when': case 'revisitwhen': entry.revisitWhen = value; break
|
|
85
89
|
case 'supersedes': entry.supersedes = value; break
|
|
@@ -95,7 +99,8 @@ function applyField(entry, label, value) {
|
|
|
95
99
|
* @returns {{ entries: Array<{
|
|
96
100
|
* id: string, number: string, version: number, title: string,
|
|
97
101
|
* type?: string, status?: string, question?: string, answer?: string,
|
|
98
|
-
* anchor?: string, domains?: string[], files?: string[],
|
|
102
|
+
* anchor?: string, domains?: string[], files?: string[], failureMode?: string, retireWhen?: string,
|
|
103
|
+
* rejectReason?: string, revisitWhen?: string,
|
|
99
104
|
* supersedes?: string, impacts?: string, normalizedRequirement?: string,
|
|
100
105
|
* selected: 'implemented'|'rejected'|null, raw: string }>, missing: boolean }}
|
|
101
106
|
* missing=true = decisions.md 不存在;selected = FR-02 入选裁决,null 表示解析保留但不入选。
|
|
@@ -166,8 +171,10 @@ export function parseDecisions(changeDir) {
|
|
|
166
171
|
return { entries, missing: false, zeroWithContent: hasDecisionLike && entries.length === 0 }
|
|
167
172
|
}
|
|
168
173
|
|
|
169
|
-
/** applyField 认识的字段标签白名单(行内 |
|
|
170
|
-
|
|
174
|
+
/** applyField 认识的字段标签白名单(行内 | 分段与缩进子项只按白名单进字段,散文误伤为零)。
|
|
175
|
+
* 故障面/退役判据(2026-09-15-tax-governance FR-01):白名单双触点之二——缺收录则两标签
|
|
176
|
+
* 永远留 raw 静默丢弃(applyField case 缺一不可)。 */
|
|
177
|
+
const FIELD_LABEL_RE = /^(type|types|status|question|问题|answer|答案|锚点|anchor|模块域|模块|domains|domain|否决理由|reject_reason|rejectreason|复潮条件|revisit_when|revisitwhen|supersedes|impacts|影响|文件|files|normalized_requirement|状态|类型|故障面|退役判据)$/i
|
|
171
178
|
|
|
172
179
|
// ---------------------------------------------------------------------------
|
|
173
180
|
// 域三级兜底(FR-03)
|
|
@@ -300,6 +307,11 @@ function renderBlockLines(entry, headHash, supersedesNote, changeName) {
|
|
|
300
307
|
lines.push(`最近确认:${oneLine(headHash) || NOT_RECORDED}`)
|
|
301
308
|
lines.push(`理由:${oneLine(entry.answer || entry.normalizedRequirement || entry.question)}`)
|
|
302
309
|
if (supersedesNote) lines.push(`supersedes:${oneLine(supersedesNote)}`)
|
|
310
|
+
// 故障面/退役判据行(FR-01/D-001@v1,2026-09-15-tax-governance):仅非空渲染——存量条目零迁移、
|
|
311
|
+
// 幂等重归档不添空行(先例 :298-299 文件行);位置在「理由:/supersedes:」之后、rejected 专属行
|
|
312
|
+
// 之前——绝不插在「锚点:」与「文件:」行之间(decision-file-field.test 行序断言红线)
|
|
313
|
+
if (entry.failureMode) lines.push(`故障面:${oneLine(entry.failureMode)}`)
|
|
314
|
+
if (entry.retireWhen) lines.push(`退役判据:${oneLine(entry.retireWhen)}`)
|
|
303
315
|
if (entry.selected === 'rejected') {
|
|
304
316
|
lines.push(`否决理由:${oneLine(entry.rejectReason)}`)
|
|
305
317
|
lines.push(`复潮条件:${oneLine(entry.revisitWhen)}`)
|
package/src/design-facts.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* decision_module_ref_invalid(ERROR)/ decision_module_domain_gap(WARNING)/
|
|
22
22
|
* decision_module_check_skipped(skipped 原因字符串,接线层包 code)。
|
|
23
23
|
*/
|
|
24
|
-
import { existsSync, readFileSync } from 'node:fs'
|
|
24
|
+
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'
|
|
@@ -134,6 +134,26 @@ export function loadModuleMap(specRoot, project) {
|
|
|
134
134
|
return { ids, prefixPairs }
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
/**
|
|
138
|
+
* 在兄弟项目的 map 里定位模块 id 归属(多项目仓 docs/<其他项目>/modules/_module-map.yaml)。
|
|
139
|
+
* 幻觉 ERROR 的消歧源(2026-09-14 quick,用户反馈①):id 真实存在但属另一子项目时报
|
|
140
|
+
* 「归属 + 本次核验只认当前项目 map」,堵 agent 按占位提示误读子项目 map 的死胡同。
|
|
141
|
+
* @param {string} specRoot 规范根目录
|
|
142
|
+
* @param {string} currentProject 当前核验项目(跳过自身)
|
|
143
|
+
* @param {string} id 模块 id
|
|
144
|
+
* @returns {string | null} 命中的项目名(docs/ 下第一段目录名),无命中/不可读 → null
|
|
145
|
+
*/
|
|
146
|
+
function locateIdInSiblingProjects(specRoot, currentProject, id) {
|
|
147
|
+
let entries
|
|
148
|
+
try { entries = readdirSync(join(specRoot, 'docs'), { withFileTypes: true }) } catch { return null }
|
|
149
|
+
for (const e of entries) {
|
|
150
|
+
if (!e.isDirectory() || e.name.startsWith('.') || e.name === currentProject) continue
|
|
151
|
+
const m = loadModuleMap(specRoot, e.name)
|
|
152
|
+
if (m && m.ids.has(id)) return e.name
|
|
153
|
+
}
|
|
154
|
+
return null
|
|
155
|
+
}
|
|
156
|
+
|
|
137
157
|
// ---------------------------------------------------------------------------
|
|
138
158
|
// 核验(FR-02 分级:ERROR / WARNING / skipped)
|
|
139
159
|
// ---------------------------------------------------------------------------
|
|
@@ -162,7 +182,8 @@ export function deriveActualModules(filePaths, prefixPairs) {
|
|
|
162
182
|
* 核验变更决策的模块域引用(FR-02/D-002/D-004;brainstorm「生成规范文件」步 --done gate 本体,
|
|
163
183
|
* 接线在 task-02)。分级:
|
|
164
184
|
* - ERROR(decision_module_ref_invalid):
|
|
165
|
-
* · 模块域项 ∉ _module-map.yaml 且无 NEW:
|
|
185
|
+
* · 模块域项 ∉ _module-map.yaml 且无 NEW: 前缀(模块幻觉)——出路提示二选一;消息内嵌
|
|
186
|
+
* 当前项目 map 路径 + 合法 id 示例,id 命中兄弟项目 map 时改报归属消歧(仍拦);
|
|
166
187
|
* · 裸项 'NEW:'(`NEW: <名>` 冒号后带空格被列表切分)——书写错误,附正确写法。
|
|
167
188
|
* - WARNING(decision_module_domain_gap):
|
|
168
189
|
* · design.md 文件清单推导的实改模块集 × 声明域并集差异(双向,措辞留人工裁量);
|
|
@@ -202,7 +223,16 @@ export function validateDecisionModuleRefs({ changeDir, specRoot, project } = {}
|
|
|
202
223
|
} else if (item.startsWith('NEW:')) {
|
|
203
224
|
// 新模块声明:豁免存在性核验(D-002/D-004),原样保留
|
|
204
225
|
} else if (!moduleMap.ids.has(item)) {
|
|
205
|
-
|
|
226
|
+
// 消息自足(2026-09-14 quick,用户反馈①):内嵌解析出的 map 路径 + 合法 id 示例,
|
|
227
|
+
// agent 不必再猜「合法 id 从哪张 map 取」;多项目仓先查兄弟项目 map 消歧——id 真实
|
|
228
|
+
// 存在但属其他子项目时报归属,而非笼统「模块幻觉」(语义不变:仍 ERROR 拦)。
|
|
229
|
+
const mapRel = `docs/${project}/modules/_module-map.yaml`
|
|
230
|
+
const ids = [...moduleMap.ids]
|
|
231
|
+
const sample = `${ids.slice(0, 5).join('、')}${ids.length > 5 ? ' 等' : ''}`
|
|
232
|
+
const sibling = locateIdInSiblingProjects(specRoot, project, item)
|
|
233
|
+
errors.push(sibling
|
|
234
|
+
? `[decision_module_ref_invalid] ${id} 模块域含未注册模块 "${item}"——该 id 属于子项目 ${sibling}(docs/${sibling}/modules/_module-map.yaml),本次核验只认当前项目 ${mapRel} 的 id(合法示例:${sample},共 ${ids.length} 个);跨项目引用请补录当前项目 map 或改用 NEW:${item} 前缀`
|
|
235
|
+
: `[decision_module_ref_invalid] ${id} 模块域含未注册模块 "${item}"——合法 id 来源:${mapRel}(示例:${sample},共 ${ids.length} 个);补录该 map 或改用 NEW:${item} 前缀声明新模块`)
|
|
206
236
|
}
|
|
207
237
|
}
|
|
208
238
|
}
|
package/src/docs-check.js
CHANGED
|
@@ -14,10 +14,95 @@
|
|
|
14
14
|
* 仅「目录递归」「目录单层」「字面路径」三形态);Windows 路径归一化;兼容 CRLF/LF。
|
|
15
15
|
*/
|
|
16
16
|
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs'
|
|
17
|
-
import { join } from 'node:path'
|
|
17
|
+
import { join, resolve as pathResolve, relative as pathRelative, sep as pathSep } from 'node:path'
|
|
18
|
+
import { execFileSync } from 'node:child_process'
|
|
18
19
|
import jsYaml from 'js-yaml'
|
|
19
20
|
import { parseModuleMapSimple } from './modules.js'
|
|
20
21
|
|
|
22
|
+
// ── HEAD 模式内容解析层(坑 docs-gate-shared-worktree-parallel-block,2026-09-08 实证)──
|
|
23
|
+
// pre-push gate 校验的语义对象是「被推送的提交树」,而磁盘工作区混有并行会话的未提交
|
|
24
|
+
// 改动——活文档锚点被在途编辑瞬时漂移,任何会话此时推送都被拦(移动靶,5→7 波动实证)。
|
|
25
|
+
// --against HEAD 模式:文档与源码内容一律取 HEAD 版本——干净文件磁盘直读(与 HEAD
|
|
26
|
+
// 逐字节一致,零额外开销)、脏文件(M/D/R)git show 取 HEAD 内容、未跟踪(??)视为
|
|
27
|
+
// 不存在(不随推送走,本就不该校验)。已知边界(接受并留档):工作区未提交删除的
|
|
28
|
+
// tracked 文档不出现在磁盘 doc 枚举里(walkGlob 扫盘)——其 HEAD 版本确属推送树,
|
|
29
|
+
// 此边角漏检远轻于移动靶误拦;裸名引用仅命中「工作区已删」源文件时层1 找不到候选。
|
|
30
|
+
// 模块级 active reader(set/try/finally restore,quicklog setQuickFileNotes 同款先例):
|
|
31
|
+
// readLines / resolveCandidates / runDocsCheck / collectInvalidDocRefs 的读统一经此层,
|
|
32
|
+
// reader 未激活时行为与磁盘直读逐字节等价(既有调用方/单测零感知)。
|
|
33
|
+
let _headReader = null
|
|
34
|
+
/** 测试与 runDocsCheck 内部使用:激活/恢复 HEAD reader(返回前值供 finally 还原)。 */
|
|
35
|
+
export function _setHeadReaderForCheck(reader) {
|
|
36
|
+
const prev = _headReader
|
|
37
|
+
_headReader = reader
|
|
38
|
+
return prev
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function _rdText(absPath) {
|
|
42
|
+
if (!_headReader) {
|
|
43
|
+
try { return readFileSync(absPath, 'utf8') } catch { return null }
|
|
44
|
+
}
|
|
45
|
+
return _headReader.readText(absPath)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function _rdExists(absPath) {
|
|
49
|
+
if (!_headReader) return existsSync(absPath)
|
|
50
|
+
return _headReader.existsAt(absPath)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 构造 HEAD 模式 reader。非 git 仓 / projectRoot 非 git 根(porcelain 路径相对仓根,
|
|
55
|
+
* 归一失配)/ git 调用失败 → 返回 null(调用方回退磁盘模式并 warn)。
|
|
56
|
+
* git status 只列差异文件:不在 status 里的 tracked 文件 = 干净(磁盘读即 HEAD 读);
|
|
57
|
+
* ?? 未跟踪 → HEAD 无此文件(existsAt=false, readText=null);M/D/R → HEAD 仍在
|
|
58
|
+
* (existsAt=true),内容经 git show(带缓存,脏文件数量级 = 并行会话在途编辑数)。
|
|
59
|
+
*/
|
|
60
|
+
export function createHeadReader(projectRoot, ref = 'HEAD') {
|
|
61
|
+
const root = pathResolve(projectRoot)
|
|
62
|
+
const git = (args) => execFileSync('git', ['-C', root, ...args], {
|
|
63
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
64
|
+
})
|
|
65
|
+
let toplevel
|
|
66
|
+
try { toplevel = git(['rev-parse', '--show-toplevel']).trim() } catch { return null }
|
|
67
|
+
if (toplevel.split(/[\\/]/).join('/') !== root.split(/[\\/]/).join('/')) return null
|
|
68
|
+
const status = new Map() // rel(正斜杠) → 'M' | 'D' | 'U'
|
|
69
|
+
try {
|
|
70
|
+
for (const line of git(['status', '--porcelain', '--', '.']).split(/\r?\n/)) {
|
|
71
|
+
if (!line) continue
|
|
72
|
+
const code = line.slice(0, 2)
|
|
73
|
+
let p = line.slice(3)
|
|
74
|
+
if (code.includes('R')) p = p.split(' -> ').pop() // 重命名记新路径
|
|
75
|
+
p = p.replace(/^"|"$/g, '')
|
|
76
|
+
const st = code.includes('?') ? 'U' : code.includes('D') ? 'D' : 'M'
|
|
77
|
+
status.set(p, st)
|
|
78
|
+
}
|
|
79
|
+
} catch { return null }
|
|
80
|
+
const showCache = new Map()
|
|
81
|
+
const relOf = (abs) => pathRelative(root, pathResolve(abs)).split(pathSep).join('/')
|
|
82
|
+
return {
|
|
83
|
+
ref,
|
|
84
|
+
readText(absPath) {
|
|
85
|
+
const rel = relOf(absPath)
|
|
86
|
+
const st = status.get(rel)
|
|
87
|
+
if (st === undefined) {
|
|
88
|
+
try { return readFileSync(absPath, 'utf8') } catch { return null } // 干净:磁盘即 HEAD
|
|
89
|
+
}
|
|
90
|
+
if (st === 'U') return null // 未跟踪:HEAD 无此文件
|
|
91
|
+
if (showCache.has(rel)) return showCache.get(rel)
|
|
92
|
+
let text = null
|
|
93
|
+
try { text = git(['show', `${ref}:${rel}`]) } catch { text = null }
|
|
94
|
+
showCache.set(rel, text)
|
|
95
|
+
return text
|
|
96
|
+
},
|
|
97
|
+
existsAt(absPath) {
|
|
98
|
+
const st = status.get(relOf(absPath))
|
|
99
|
+
if (st === 'U') return false // 未跟踪 → HEAD 不存在
|
|
100
|
+
if (st === 'D' || st === 'M') return true // 工作区删/改 → HEAD 仍在
|
|
101
|
+
return existsSync(absPath)
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
21
106
|
/** 提取 file.js:line / file.js:start-end 引用(反引号包裹与裸文本均命中,全文扫描 D-006)。
|
|
22
107
|
* 扩展名覆盖(archify 借鉴 P1b,2026-09-04):js/mjs/cjs/ts/tsx/jsx/py/java/go——
|
|
23
108
|
* scan 产物面向任意技术栈项目(如 SillyHub = Python + TypeScript),只认 .js 会让
|
|
@@ -268,16 +353,19 @@ function findInTree(dir, baseName, rel = '', treeCache = null) {
|
|
|
268
353
|
export function resolveCandidates(projectRoot, refFile, treeCache = null) {
|
|
269
354
|
if (refFile.includes('/')) {
|
|
270
355
|
const direct = join(projectRoot, refFile)
|
|
271
|
-
if (
|
|
356
|
+
if (_rdExists(direct)) return [direct]
|
|
272
357
|
const inSrc = join(projectRoot, 'src', refFile)
|
|
273
|
-
if (
|
|
358
|
+
if (_rdExists(inSrc)) return [inSrc]
|
|
274
359
|
const slash = refFile.lastIndexOf('/')
|
|
275
360
|
const baseName = slash === -1 ? refFile : refFile.slice(slash + 1)
|
|
276
361
|
return findInTree(join(projectRoot, 'src'), baseName, '', treeCache)
|
|
277
362
|
.filter((rel) => ('src/' + rel).endsWith('/' + refFile) || 'src/' + rel === 'src/' + refFile)
|
|
278
363
|
.map((rel) => join(projectRoot, 'src', rel))
|
|
364
|
+
.filter((abs) => _rdExists(abs)) // HEAD 模式:未跟踪源文件不进推送树,不作候选
|
|
279
365
|
}
|
|
280
|
-
return findInTree(join(projectRoot, 'src'), refFile, '', treeCache)
|
|
366
|
+
return findInTree(join(projectRoot, 'src'), refFile, '', treeCache)
|
|
367
|
+
.map((rel) => join(projectRoot, 'src', rel))
|
|
368
|
+
.filter((abs) => _rdExists(abs))
|
|
281
369
|
}
|
|
282
370
|
|
|
283
371
|
/**
|
|
@@ -301,9 +389,8 @@ export function collectInvalidDocRefs(projectRoot, docEntries) {
|
|
|
301
389
|
const linesCache = new Map()
|
|
302
390
|
for (const { name, absPath } of docEntries) {
|
|
303
391
|
let content
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
} catch {
|
|
392
|
+
content = _rdText(absPath)
|
|
393
|
+
if (content === null) {
|
|
307
394
|
readFailures++
|
|
308
395
|
continue
|
|
309
396
|
}
|
|
@@ -355,7 +442,9 @@ function readLines(absPath, linesCache = null) {
|
|
|
355
442
|
return lines
|
|
356
443
|
}
|
|
357
444
|
try {
|
|
358
|
-
const
|
|
445
|
+
const text = _rdText(absPath)
|
|
446
|
+
if (text === null) return null
|
|
447
|
+
const lines = text.split(/\r?\n/)
|
|
359
448
|
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
|
|
360
449
|
return lines
|
|
361
450
|
} catch { return null }
|
|
@@ -479,6 +568,28 @@ function relDisplay(absPath, projectRoot) {
|
|
|
479
568
|
* @throws {DocsCheckConfigError} glob 形态不支持
|
|
480
569
|
*/
|
|
481
570
|
export function runDocsCheck(opts) {
|
|
571
|
+
// --against <ref>(坑 docs-gate-shared-worktree-parallel-block):pre-push gate 校验
|
|
572
|
+
// 「被推送的提交树」而非工作区——激活 HEAD reader 隔离并行会话在途编辑(移动靶)。
|
|
573
|
+
// 非 git 仓/子目录/git 异常 → 回退磁盘模式并附 warning。--fix 路径不经此(applyFixes
|
|
574
|
+
// 恒操作工作区磁盘内容,against 对 fix 无意义,CLI 层已拒绝组合)。
|
|
575
|
+
const against = opts && opts.against ? String(opts.against) : null
|
|
576
|
+
if (!against) return _runDocsCheckCore(opts)
|
|
577
|
+
const reader = createHeadReader(opts.projectRoot, against)
|
|
578
|
+
if (!reader) {
|
|
579
|
+
const r = _runDocsCheckCore(opts)
|
|
580
|
+
r.warnings = [...(r.warnings || []),
|
|
581
|
+
`--against ${against} 不可用(非 git 仓 / 非 git 根子目录 / git 异常),已回退磁盘模式——并行在途编辑可能造成移动靶误拦`]
|
|
582
|
+
return r
|
|
583
|
+
}
|
|
584
|
+
const prev = _setHeadReaderForCheck(reader)
|
|
585
|
+
try {
|
|
586
|
+
return _runDocsCheckCore(opts)
|
|
587
|
+
} finally {
|
|
588
|
+
_setHeadReaderForCheck(prev)
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function _runDocsCheckCore(opts) {
|
|
482
593
|
// paths/docs 显式传 null(readDocsCheckConfig 无 local.yaml 段时的回退值)须落回缺省 glob,
|
|
483
594
|
// 解构默认值只挡 undefined 不挡 null——docs-check 无配置裸跑曾因此 null.flatMap 崩溃。
|
|
484
595
|
const {
|
|
@@ -511,7 +622,10 @@ export function runDocsCheck(opts) {
|
|
|
511
622
|
|
|
512
623
|
for (const docRel of docFiles) {
|
|
513
624
|
const docAbs = join(projectRoot, docRel)
|
|
514
|
-
|
|
625
|
+
// HEAD 模式:未跟踪文档(walkGlob 扫盘枚举到但不随推送走)静默跳过——不是失效,
|
|
626
|
+
// 也不该以「文档不存在」入 invalid(它存在于工作区,只是不属于被推送的提交树)。
|
|
627
|
+
if (_headReader && !_rdExists(docAbs)) { skippedExempt++; continue }
|
|
628
|
+
if (!_rdExists(docAbs)) {
|
|
515
629
|
invalid.push({
|
|
516
630
|
doc: docRel, docLine: 0, ref: '', reason: '文档不存在',
|
|
517
631
|
fix: { fixable: false, reason: '文档不存在,无法自动定位' },
|
|
@@ -521,7 +635,16 @@ export function runDocsCheck(opts) {
|
|
|
521
635
|
// 豁免双通道(FR-3 / D-003):路径段判定免 IO(readFileSync 前);
|
|
522
636
|
// frontmatter 判定需读文件(读取后)。豁免文档整体跳过不计 invalid。
|
|
523
637
|
if (exempt && isExemptDocPath(docRel)) { skippedExempt++; continue }
|
|
524
|
-
const md =
|
|
638
|
+
const md = _rdText(docAbs)
|
|
639
|
+
if (md === null) {
|
|
640
|
+
// HEAD 模式下未跟踪文档(不随推送走,walkGlob 扫盘枚举到此)——不计失效不报警
|
|
641
|
+
if (_headReader) { skippedExempt++; continue }
|
|
642
|
+
invalid.push({
|
|
643
|
+
doc: docRel, docLine: 0, ref: '', reason: '文档不可读',
|
|
644
|
+
fix: { fixable: false, reason: '文档不可读(权限/路径异常)' },
|
|
645
|
+
})
|
|
646
|
+
continue
|
|
647
|
+
}
|
|
525
648
|
if (exempt && isExemptDocFrontmatter(md)) { skippedExempt++; continue }
|
|
526
649
|
const refs = collectDocRefs(md)
|
|
527
650
|
const mdLines = md.split(/\r?\n/)
|
|
@@ -836,10 +959,12 @@ export const DECISIONS_DEFAULT_BEHIND_THRESHOLD = 10
|
|
|
836
959
|
* @param {string} yamlText local.yaml 全文
|
|
837
960
|
* @returns {string[]} 键列表;无声明返回 []
|
|
838
961
|
*/
|
|
839
|
-
function extractKnownFailureKeys(yamlText) {
|
|
962
|
+
export function extractKnownFailureKeys(yamlText) {
|
|
840
963
|
if (!yamlText) return []
|
|
841
964
|
const yaml = String(yamlText).replace(/\r\n?/g, '\n')
|
|
842
|
-
|
|
965
|
+
// 贪婪 .* 同 verify-postcheck.extractKnownFailures(坑 known-failures-inline-nested-bracket:
|
|
966
|
+
// 值内 ] 使 [^\]]* 截断、整表静默清空;两处复刻必须同口径互指)
|
|
967
|
+
const inline = yaml.match(/^known_failures:\s*\[(.*)\]\s*(?:#.*)?$/m)
|
|
843
968
|
if (inline) {
|
|
844
969
|
return inline[1].split(',').map(s => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean)
|
|
845
970
|
}
|
package/src/docs-gate.js
CHANGED
|
@@ -11,10 +11,19 @@
|
|
|
11
11
|
*
|
|
12
12
|
* exit code:0 过(≤基线)/ 1 拦(>基线)/ 2 配置或 IO 错误(含无基线)。
|
|
13
13
|
* 纯判定逻辑(evaluateRatchet)与 IO 面(runDocsGate)分离,前者可单测。
|
|
14
|
+
*
|
|
15
|
+
* 坑 docs-gate-stale-baseline(ql-20260915-004,2026-09-15 用户实证):基线文件是静态快照,
|
|
16
|
+
* 远端 origin/main 已合入的失效消化/新增不会回流本地基线——出现「基线 371 < origin/main
|
|
17
|
+
* 实测 379、本地 current 379」时 ratchet 拦 379>371,但本次推送零增量(不劣于远端)——
|
|
18
|
+
* ratchet 本质=拦增量,被陈旧基线破坏成拦存量。修复:current > baseline 分支先实测
|
|
19
|
+
* origin/main 树(临时 detach worktree 跑 runDocsCheck),current ≤ 实测值即放行 + 提示
|
|
20
|
+
* 重锚;实测失败/无远端 ref fail-open 回原拦。快路径(current ≤ baseline)零成本零变化。
|
|
14
21
|
*/
|
|
15
|
-
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
22
|
+
import { readFileSync, writeFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
|
|
16
23
|
import { join } from 'node:path';
|
|
24
|
+
import { tmpdir } from 'node:os';
|
|
17
25
|
import { runDocsCheck, readDocsCheckConfig, DocsCheckConfigError } from './docs-check.js';
|
|
26
|
+
import { safeGit } from './git-helper.js';
|
|
18
27
|
|
|
19
28
|
/** 基线文件名(相对 specBase/.sillyspec 根;specBase 由调用方传入完整路径锚) */
|
|
20
29
|
export const BASELINE_FILENAME = 'docs-check-baseline';
|
|
@@ -58,6 +67,73 @@ export function writeBaseline(specBase, value) {
|
|
|
58
67
|
writeFileSync(join(specBase, BASELINE_FILENAME), `${value}\n`, 'utf8');
|
|
59
68
|
}
|
|
60
69
|
|
|
70
|
+
/**
|
|
71
|
+
* 实测远端基准树的 docs check 失效数(坑 docs-gate-stale-baseline,ql-20260915-004)。
|
|
72
|
+
*
|
|
73
|
+
* 为什么临时 worktree 而非 runDocsCheck 的 --against reader:against reader 的干净文件
|
|
74
|
+
* 走磁盘直读(磁盘 == HEAD 才成立,docs-check.js createHeadReader :88)——ref=origin/main
|
|
75
|
+
* 时本地领先 origin 的已提交内容会被误当 origin 版,实测失真。临时 detach worktree
|
|
76
|
+
* (`git worktree add --detach <tmp> origin/main`)检出的是真远端树,磁盘即实测基。
|
|
77
|
+
*
|
|
78
|
+
* 配置口径:读树内 local.yaml(gitignored → 通常缺省 → DEFAULT_DOC_PATHS,与远端真实
|
|
79
|
+
* CI 口径一致——设备本地 skip 定制不污染远端实测)。清理含 .git 锁容错:worktree remove
|
|
80
|
+
* 失败 → prune + rmSync 兜底;仍失败 stderr 一行不抛(临时目录残留 OS 清理,不阻断 gate)。
|
|
81
|
+
*
|
|
82
|
+
* @param {string} projectRoot 源码仓根
|
|
83
|
+
* @param {string[]} [remoteRefs] 候选远端 ref(origin/main 优先;master 缺省仓兜底)
|
|
84
|
+
* @returns {Promise<{ originCount: number|null, ref: string|null, error: string|null }>}
|
|
85
|
+
* originCount null = 未实测(无 ref / worktree 建/跑失败——调用方 fail-open 回原拦)
|
|
86
|
+
*/
|
|
87
|
+
export async function measureRemoteBaselineCount(projectRoot, remoteRefs = ['origin/main', 'origin/master']) {
|
|
88
|
+
let ref = null;
|
|
89
|
+
for (const r of remoteRefs) {
|
|
90
|
+
if ((safeGit(projectRoot, ['rev-parse', '--verify', '--quiet', r]) || {}).value) { ref = r; break; }
|
|
91
|
+
}
|
|
92
|
+
if (!ref) return { originCount: null, ref: null, error: 'no-remote-ref' };
|
|
93
|
+
|
|
94
|
+
const tmp = mkdtempSync(join(tmpdir(), 'sillyspec-docsgate-remote-'));
|
|
95
|
+
let originCount = null;
|
|
96
|
+
let error = null;
|
|
97
|
+
try {
|
|
98
|
+
// git worktree add 接受已存在的空目录(mkdtempSync 产物);失败 fail-open 由调用方回原拦
|
|
99
|
+
const added = safeGit(projectRoot, ['worktree', 'add', '--detach', tmp, ref], { timeout: 120000 });
|
|
100
|
+
if (added.error) {
|
|
101
|
+
error = added.error;
|
|
102
|
+
return { originCount: null, ref, error };
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const cfg = readDocsCheckConfig(tmp);
|
|
106
|
+
const res = runDocsCheck({
|
|
107
|
+
projectRoot: tmp,
|
|
108
|
+
paths: cfg.paths,
|
|
109
|
+
skip: cfg.skip,
|
|
110
|
+
keywordAssert: cfg.keywordAssert,
|
|
111
|
+
crossRepoRoots: cfg.crossRepoRoots,
|
|
112
|
+
});
|
|
113
|
+
originCount = res.invalid.length;
|
|
114
|
+
} catch (e) {
|
|
115
|
+
if (e instanceof DocsCheckConfigError) {
|
|
116
|
+
error = e.message;
|
|
117
|
+
originCount = null;
|
|
118
|
+
} else {
|
|
119
|
+
throw e;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
} finally {
|
|
123
|
+
try {
|
|
124
|
+
const removed = safeGit(projectRoot, ['worktree', 'remove', '--force', tmp]);
|
|
125
|
+
if (removed.error) {
|
|
126
|
+
// .git 锁/并发竞争容错:prune 清注册残留 + 直删目录兜底
|
|
127
|
+
safeGit(projectRoot, ['worktree', 'prune']);
|
|
128
|
+
try { rmSync(tmp, { recursive: true, force: true }); } catch { /* 目录已不在 */ }
|
|
129
|
+
}
|
|
130
|
+
} catch {
|
|
131
|
+
try { rmSync(tmp, { recursive: true, force: true }); } catch { /* 同上 */ }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return { originCount, ref, error };
|
|
135
|
+
}
|
|
136
|
+
|
|
61
137
|
/**
|
|
62
138
|
* IO 入口:跑一次 gate。
|
|
63
139
|
* @param {{ projectRoot: string, specBase: string, initBaseline?: boolean }} opts
|
|
@@ -77,10 +153,13 @@ export async function runDocsGate(opts = {}, checkOpts = {}) {
|
|
|
77
153
|
skip: checkOpts.skip || cfg.skip,
|
|
78
154
|
keywordAssert: checkOpts.keywordAssert ?? cfg.keywordAssert,
|
|
79
155
|
crossRepoRoots: checkOpts.crossRepoRoots || cfg.crossRepoRoots,
|
|
156
|
+
// 坑 docs-gate-shared-worktree-parallel-block:--against <ref> 透传——按提交树校验
|
|
157
|
+
//(pre-push 语义),隔离并行会话在途编辑的锚点瞬时漂移(移动靶)
|
|
158
|
+
...(checkOpts.against ? { against: checkOpts.against } : {}),
|
|
80
159
|
});
|
|
81
160
|
} catch (e) {
|
|
82
161
|
if (e instanceof DocsCheckConfigError) {
|
|
83
|
-
return { exitCode: 2, ok: false, current: null, baseline: null, delta: null, message: `docs gate 配置错误:${e.message}`, inited: false };
|
|
162
|
+
return { exitCode: 2, ok: false, current: null, baseline: null, delta: null, originCount: null, message: `docs gate 配置错误:${e.message}`, inited: false };
|
|
84
163
|
}
|
|
85
164
|
throw e;
|
|
86
165
|
}
|
|
@@ -88,20 +167,43 @@ export async function runDocsGate(opts = {}, checkOpts = {}) {
|
|
|
88
167
|
|
|
89
168
|
if (initBaseline) {
|
|
90
169
|
writeBaseline(specBase, current);
|
|
91
|
-
return { exitCode: 0, ok: true, current, baseline: current, delta: 0, message: `📌 docs gate: 基线已初始化为当前实测 ${current} 处失效(${baselineDisplay(specBase)})`, inited: true };
|
|
170
|
+
return { exitCode: 0, ok: true, current, baseline: current, delta: 0, originCount: null, message: `📌 docs gate: 基线已初始化为当前实测 ${current} 处失效(${baselineDisplay(specBase)})`, inited: true };
|
|
92
171
|
}
|
|
93
172
|
|
|
94
173
|
const baseline = readBaseline(specBase);
|
|
95
174
|
if (baseline === null) {
|
|
96
175
|
return {
|
|
97
|
-
exitCode: 2, ok: false, current, baseline: null, delta: null,
|
|
176
|
+
exitCode: 2, ok: false, current, baseline: null, delta: null, originCount: null,
|
|
98
177
|
message: `❌ docs gate: 无基线文件(${baselineDisplay(specBase)})。首次使用先跑 sillyspec docs gate --init-baseline(以当前实测数立基线,存量既往不咎只拦增量)`,
|
|
99
178
|
inited: false,
|
|
100
179
|
};
|
|
101
180
|
}
|
|
102
181
|
if (Number.isNaN(baseline)) {
|
|
103
|
-
return { exitCode: 2, ok: false, current, baseline: null, delta: null, message: `❌ docs gate: 基线文件损坏(非非负整数),手工修正或 --init-baseline 重置`, inited: false };
|
|
182
|
+
return { exitCode: 2, ok: false, current, baseline: null, delta: null, originCount: null, message: `❌ docs gate: 基线文件损坏(非非负整数),手工修正或 --init-baseline 重置`, inited: false };
|
|
104
183
|
}
|
|
105
184
|
const v = evaluateRatchet({ current, baseline });
|
|
106
|
-
|
|
185
|
+
if (v.ok) {
|
|
186
|
+
// 快路径(current ≤ baseline):原路零变化——不触远端实测(零 git 成本零行为漂移)
|
|
187
|
+
return { exitCode: 0, ok: true, current, baseline, delta: v.delta, originCount: null, message: v.message, inited: false };
|
|
188
|
+
}
|
|
189
|
+
// 坑 docs-gate-stale-baseline(ql-20260915-004):current > baseline 时 origin 实测兜底——
|
|
190
|
+
// ratchet 本质=拦增量;基线是静态快照,远端已合入的失效增长不回流基线会造成「未劣于远端
|
|
191
|
+
// 也被拦」的假拦。实测 origin/main 树:current ≤ 实测值 = 本次不劣于远端 → 放行 + 提示
|
|
192
|
+
// 重锚;无远端 ref / 实测失败 fail-open 回原拦;current > 实测值 = 真增量劣于远端 → 拦。
|
|
193
|
+
const measured = await measureRemoteBaselineCount(projectRoot);
|
|
194
|
+
if (measured.originCount !== null && current <= measured.originCount) {
|
|
195
|
+
return {
|
|
196
|
+
exitCode: 0, ok: true, current, baseline, delta: v.delta, originCount: measured.originCount,
|
|
197
|
+
message: `⚠️ docs gate: 基线陈旧:基线 ${baseline} < ${measured.ref} 实测 ${measured.originCount},本次 ${current} 处失效未劣于远端不拦——建议 sillyspec docs gate --init-baseline 重锚锁定(以当前实测 ${current} 立线,存量既往不咎只拦增量)`,
|
|
198
|
+
inited: false,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
const remoteNote = measured.originCount !== null
|
|
202
|
+
? `,且劣于 ${measured.ref} 实测 ${measured.originCount} 处(真增量)`
|
|
203
|
+
: '';
|
|
204
|
+
return {
|
|
205
|
+
exitCode: 1, ok: false, current, baseline, delta: v.delta, originCount: measured.originCount,
|
|
206
|
+
message: `❌ docs gate: ${current} 处失效 > 基线 ${baseline}(新增 ${v.delta} 处)${remoteNote},拦截。修掉新增引用或显式 --init-baseline 重置基线(需你确认存量合法)`,
|
|
207
|
+
inited: false,
|
|
208
|
+
};
|
|
107
209
|
}
|