sillyspec 3.27.9 → 3.27.12
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 +214 -214
- package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
- package/.claude/skills/sillyspec-quick/SKILL.md +116 -116
- package/README.md +3 -2
- package/package.json +1 -1
- package/src/change-delete.js +181 -0
- package/src/commit-suggest.js +22 -0
- package/src/contract-matrix.js +20 -4
- package/src/decision-distill.js +33 -14
- package/src/docs-check.js +138 -2
- package/src/endpoint-extractor.js +7 -4
- package/src/facade-hint.js +170 -0
- package/src/index.js +259 -22
- package/src/init.js +104 -27
- package/src/module-changelog.js +116 -0
- package/src/progress/change-registry.js +28 -0
- package/src/progress/shared.js +6 -0
- package/src/progress/stage-machine.js +85 -8
- package/src/progress.js +3 -0
- package/src/run/command.js +60 -0
- package/src/run/complete-handlers.js +25 -1
- package/src/run/gates.js +62 -5
- package/src/run/prompt.js +12 -1
- package/src/run/shared.js +81 -23
- package/src/run/stage.js +21 -1
- package/src/spec-sync.js +88 -1
- package/src/stages/archive.js +5 -0
- package/src/stages/execute.js +3 -1
- package/src/sync.js +453 -17
- package/src/verify-postcheck.js +47 -6
- package/src/workflow.js +4 -0
- package/src/worktree-apply.js +62 -4
- package/src/worktree.js +6 -4
- package/templates/{claude-instruction.md → agents-instruction.md} +2 -2
- package/templates/prompts/verify-probes.md +2 -1
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* change-delete.js — 变更删除命令(2026-08-30 用户反馈①)
|
|
3
|
+
*
|
|
4
|
+
* 背景:此前删除一个变更要靠「git rm 目录 + 借道 doctor --cleanup-ghosts 清 DB 行」两步,
|
|
5
|
+
* 且幽灵清理把删除行的 status 写成 archived——DB 无法区分「流程正常收尾的归档」与
|
|
6
|
+
* 「中途废弃的删除」,事后审计只能回溯 git。
|
|
7
|
+
*
|
|
8
|
+
* 本命令提供一等删除路径:
|
|
9
|
+
* - DB:status='deleted'(与 archived 语义分离,DB 可直接审计区分,行保留不物理删)
|
|
10
|
+
* - 目录:changes/<name>/ 物理移除(git tracked 文件删除后照常 commit,历史仍可回溯)
|
|
11
|
+
* - worktree:复用 archiveWorktreeCleanup(有未 apply 变更时保留 worktree 只警告——
|
|
12
|
+
* 不因删变更丢用户唯一副本的代码;顺带清 execute/stage-review runId marker)
|
|
13
|
+
* - git:safeGit add -A 暂存目录删除(best-effort,对齐 archive CLI 下沉 git add)
|
|
14
|
+
* - 平台:triggerSync 推终态(sync.js 对 status='deleted' 上行墓碑,平台收敛软删)
|
|
15
|
+
*
|
|
16
|
+
* 两段式安全(对齐 --cleanup-remnant / --cleanup-ghosts / --align-execute-progress 家族):
|
|
17
|
+
* 默认 dry-run 只报告将发生什么,--confirm 才执行。
|
|
18
|
+
*
|
|
19
|
+
* 执行序(DB-first + 失败补偿,对齐 renameChange 的回滚模式):DB 翻 deleted → 删目录,
|
|
20
|
+
* 目录删除失败则把 DB 行翻回原 status,避免「目录还在但行已 deleted」的分裂态。
|
|
21
|
+
*/
|
|
22
|
+
import { existsSync, readdirSync, rmSync, statSync } from 'fs';
|
|
23
|
+
import { join, relative } from 'path';
|
|
24
|
+
import { safeGit } from './git-helper.js';
|
|
25
|
+
import { assertSafeChangeName, triggerSync } from './run/shared.js';
|
|
26
|
+
import { archiveWorktreeCleanup } from './run/complete-handlers.js';
|
|
27
|
+
|
|
28
|
+
function countFiles(dir) {
|
|
29
|
+
let n = 0;
|
|
30
|
+
let entries;
|
|
31
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return 0; }
|
|
32
|
+
for (const e of entries) {
|
|
33
|
+
if (e.isDirectory()) n += countFiles(join(dir, e.name));
|
|
34
|
+
else n++;
|
|
35
|
+
}
|
|
36
|
+
return n;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 探测变更的 worktree 状态(dry-run 也报——「删了会不会丢代码」是关键决策信息)。
|
|
41
|
+
* meta 缺失 / 探测失败 → null(无 worktree),不阻断。
|
|
42
|
+
*/
|
|
43
|
+
async function probeWorktree(cwd, changeName) {
|
|
44
|
+
try {
|
|
45
|
+
const { WorktreeManager } = await import('./worktree.js');
|
|
46
|
+
const wm = new WorktreeManager({ cwd });
|
|
47
|
+
const meta = wm.getMeta(changeName);
|
|
48
|
+
if (!meta) return null;
|
|
49
|
+
const info = { mode: meta.mode || null, worktreePath: meta.worktreePath || null, unappliedChanges: 0 };
|
|
50
|
+
if (meta.mode !== 'in-place-fallback') {
|
|
51
|
+
const check = wm.hasUnappliedChanges(changeName);
|
|
52
|
+
if (check && check.hasChanges) info.unappliedChanges = (check.changedFiles || []).length;
|
|
53
|
+
}
|
|
54
|
+
return info;
|
|
55
|
+
} catch { return null; }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 删除变更(两段式)。
|
|
60
|
+
* @param {{cwd: string, specDir?: string|null, changeName: string, confirm?: boolean}} opts
|
|
61
|
+
* @returns {Promise<{ok: boolean, reason?: string, action?: 'dry_run'|'deleted', change?: string,
|
|
62
|
+
* db_row?: {status: string, current_stage: string|null, last_active: string|null}|null,
|
|
63
|
+
* dir?: {path: string, files: number}|null, worktree?: object|null,
|
|
64
|
+
* db_updated?: boolean, dir_removed?: boolean, files_removed?: number, warnings?: string[]}>}
|
|
65
|
+
*/
|
|
66
|
+
export async function deleteChange({ cwd, specDir = null, changeName, confirm = false }) {
|
|
67
|
+
if (!changeName) return { ok: false, reason: '缺少变更名(sillyspec change-delete <名> 或 --change <名>)' };
|
|
68
|
+
try {
|
|
69
|
+
assertSafeChangeName(changeName, '变更名');
|
|
70
|
+
} catch (e) {
|
|
71
|
+
return { ok: false, reason: e.message };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const { ProgressManager } = await import('./progress.js');
|
|
75
|
+
const pm = new ProgressManager({ specDir: specDir || null });
|
|
76
|
+
const specBase = specDir || join(cwd, '.sillyspec');
|
|
77
|
+
const dirPath = join(specBase, 'changes', changeName);
|
|
78
|
+
|
|
79
|
+
// 只读探测 DB 行(不存在 DB 时 _ensureDB 会建空库——先探目录避免为纯孤儿目录凭空建库)
|
|
80
|
+
const dirExists = existsSync(dirPath);
|
|
81
|
+
let row = null;
|
|
82
|
+
if (dirExists || existsSync(join(specBase, '.runtime', 'sillyspec.db')) || existsSync(join(specBase, 'sillyspec.db'))) {
|
|
83
|
+
try {
|
|
84
|
+
const sqlDb = pm._ensureDB(cwd).getDb();
|
|
85
|
+
const r = sqlDb.prepare('SELECT name, status, current_stage, last_active FROM changes WHERE name = ?').get(changeName);
|
|
86
|
+
if (r !== undefined) row = { status: r.status, current_stage: r.current_stage, last_active: r.last_active };
|
|
87
|
+
} catch (e) {
|
|
88
|
+
return { ok: false, reason: `读取进度库失败:${e.message}` };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!row && !dirExists) {
|
|
93
|
+
return { ok: false, reason: `变更 ${changeName} 不存在(DB 无行且目录缺失)` };
|
|
94
|
+
}
|
|
95
|
+
if (row && row.status === 'archived') {
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
reason: `变更 ${changeName} 已是归档态(archived)——change-delete 面向活跃/残留变更;归档历史如需清理请直接处理 changes/archive/ 目录(git 层操作)`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const alreadyDeleted = row && row.status === 'deleted';
|
|
102
|
+
if (alreadyDeleted && !dirExists) {
|
|
103
|
+
return { ok: false, reason: `变更 ${changeName} 已是删除态(deleted)且无残留目录,无需重复删除` };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const dirFiles = dirExists ? countFiles(dirPath) : 0;
|
|
107
|
+
const worktreeInfo = await probeWorktree(cwd, changeName);
|
|
108
|
+
|
|
109
|
+
if (!confirm) {
|
|
110
|
+
return {
|
|
111
|
+
ok: true, action: 'dry_run', change: changeName,
|
|
112
|
+
db_row: row, dir: dirExists ? { path: dirPath, files: dirFiles } : null,
|
|
113
|
+
worktree: worktreeInfo,
|
|
114
|
+
already_deleted: alreadyDeleted,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const warnings = [];
|
|
119
|
+
// 1. DB 翻 deleted(已是 deleted 的残留目录清理跳过——幂等)
|
|
120
|
+
let dbUpdated = false;
|
|
121
|
+
if (row && !alreadyDeleted) {
|
|
122
|
+
try {
|
|
123
|
+
pm.deleteChange(cwd, changeName);
|
|
124
|
+
dbUpdated = true;
|
|
125
|
+
} catch (e) {
|
|
126
|
+
return { ok: false, reason: `更新进度库失败:${e.message}` };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// 2. 删目录(失败回滚 DB,对齐 renameChange 的补偿模式)
|
|
130
|
+
if (dirExists) {
|
|
131
|
+
try {
|
|
132
|
+
rmSync(dirPath, { recursive: true, force: true });
|
|
133
|
+
} catch (e) {
|
|
134
|
+
if (dbUpdated) {
|
|
135
|
+
try {
|
|
136
|
+
const sqlDb = pm._ensureDB(cwd).getDb();
|
|
137
|
+
sqlDb.prepare('UPDATE changes SET status = ? WHERE name = ?').run(row.status, changeName);
|
|
138
|
+
} catch (re) {
|
|
139
|
+
warnings.push(`目录删除失败且 DB 回滚也失败(状态分裂,跑 sillyspec doctor --json 排查):${re.message}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return { ok: false, reason: `删除目录失败:${e.message}`, warnings };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// 3. worktree 清理(archiveWorktreeCleanup 内建「未 apply 变更保留」护栏,不丢代码)
|
|
146
|
+
try {
|
|
147
|
+
await archiveWorktreeCleanup(cwd, changeName, specBase, {});
|
|
148
|
+
} catch (e) {
|
|
149
|
+
warnings.push(`worktree 清理失败(不阻断删除):${e.message}`);
|
|
150
|
+
}
|
|
151
|
+
// 4. git 暂存目录删除(best-effort:非 git 仓 / specBase 在仓外时跳过,git status 照常可见)
|
|
152
|
+
try {
|
|
153
|
+
const relDir = relative(cwd, dirPath).split('\\').join('/');
|
|
154
|
+
if (relDir && !relDir.startsWith('../')) {
|
|
155
|
+
safeGit(cwd, ['add', '-A', '--', relDir]);
|
|
156
|
+
}
|
|
157
|
+
} catch { /* 非 git 仓等场景:删除在 git status 可见,暂存非必需 */ }
|
|
158
|
+
// 5. 平台终态/墓碑上行(triggerSync 内建 8s 熔断 + 未连接静默;change-delete 是终态
|
|
159
|
+
// 一次性命令,await 保证墓碑发出后再退出,输出顺序确定)
|
|
160
|
+
try {
|
|
161
|
+
await triggerSync(cwd, changeName, { specRoot: specBase });
|
|
162
|
+
} catch (e) {
|
|
163
|
+
warnings.push(`平台终态同步失败(不阻断删除,下次常规同步重试):${e.message}`);
|
|
164
|
+
}
|
|
165
|
+
// 6. 审计留痕(破坏性操作,对齐 complete-stage --force 的审计口径)
|
|
166
|
+
try {
|
|
167
|
+
pm._appendAuditLog(cwd, {
|
|
168
|
+
action: 'change-delete',
|
|
169
|
+
change: changeName,
|
|
170
|
+
db_status_before: row ? row.status : null,
|
|
171
|
+
dir_removed: dirExists,
|
|
172
|
+
files_removed: dirFiles,
|
|
173
|
+
});
|
|
174
|
+
} catch { /* 审计失败不阻断 */ }
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
ok: true, action: 'deleted', change: changeName,
|
|
178
|
+
db_updated: dbUpdated, dir_removed: dirExists, files_removed: dirFiles,
|
|
179
|
+
worktree: worktreeInfo, warnings,
|
|
180
|
+
};
|
|
181
|
+
}
|
package/src/commit-suggest.js
CHANGED
|
@@ -71,6 +71,28 @@ function classifyStagePath(p) {
|
|
|
71
71
|
return null
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* 暂存区快照(坑 same-main-staging-pollution,2026-08-28 用户实证:同 main 两个活跃会话
|
|
76
|
+
* 共享同一暂存区,A 的 commit 把 B 暂存的文件扫入——本轮竞态事故根因,无任何防护)。
|
|
77
|
+
* 防护 = 提交前可见:commit 提示里带 `git diff --cached --name-only` 快照 + 他者文件告警。
|
|
78
|
+
* @param {{ cwd: string, ownFiles?: string[]|null }} opts
|
|
79
|
+
* ownFiles:本会话/本变更的精确文件集(apply-pathspec 等);提供时对快照做差集,
|
|
80
|
+
* 不在集内的暂存文件 = 他者会话暂存(告警对象)。null/空数组 = 只出快照不做归属判定。
|
|
81
|
+
* @returns {{ available: boolean, staged: string[], foreign: string[] }}
|
|
82
|
+
* available=false:git 不可用/非仓库(调用方静默跳过,不误报)。
|
|
83
|
+
*/
|
|
84
|
+
export function collectStagedArea({ cwd, ownFiles = null }) {
|
|
85
|
+
const raw = gitQuiet(cwd, ['diff', '--cached', '--name-only'])
|
|
86
|
+
if (raw === null) return { available: false, staged: [], foreign: [] }
|
|
87
|
+
const staged = raw.split('\n').filter(Boolean).map(f => f.replace(/\\/g, '/'))
|
|
88
|
+
let foreign = []
|
|
89
|
+
if (Array.isArray(ownFiles) && ownFiles.length > 0) {
|
|
90
|
+
const own = new Set(ownFiles.map(f => String(f).replace(/\\/g, '/')))
|
|
91
|
+
foreign = staged.filter(f => !own.has(f))
|
|
92
|
+
}
|
|
93
|
+
return { available: true, staged, foreign }
|
|
94
|
+
}
|
|
95
|
+
|
|
74
96
|
/**
|
|
75
97
|
* 收集提交上下文并生成建议 message。
|
|
76
98
|
* @param {{ cwd: string, specDir?: string|null }} opts
|
package/src/contract-matrix.js
CHANGED
|
@@ -392,10 +392,13 @@ export function _readWorktreeMeta(specBase, scanRoot, changeName) {
|
|
|
392
392
|
|
|
393
393
|
/**
|
|
394
394
|
* 取本变更 diff 文件集(parity 前端调用收窄用,坑 probe5-fullrepo-frontend-noise)。
|
|
395
|
-
* 优先 worktree meta 锚点 diff(与 verify 同源);无 meta 退主仓 git diff HEAD
|
|
395
|
+
* 优先 worktree meta 锚点 diff(与 verify 同源);无 meta 退主仓 git diff HEAD;
|
|
396
|
+
* 两者皆空再退 apply-pathspec-<change>.txt(apply 落盘的精确清单)。
|
|
397
|
+
* 兜底链每级都空才返回 null/[](调用方回退全仓——坑 probe5-single-task-artifact-scope,
|
|
398
|
+
* 2026-08-28 实证:apply+commit/cleanup 后 diff 为空 → 全仓调用 × 局部端点 = 150 条假 missing)。
|
|
396
399
|
* @returns {string[]|null}
|
|
397
400
|
*/
|
|
398
|
-
function _resolveDiffFilesForParity(specBase, scanRoot, changeName) {
|
|
401
|
+
function _resolveDiffFilesForParity(specBase, scanRoot, changeName, runtimeRoot) {
|
|
399
402
|
try {
|
|
400
403
|
const wt = _readWorktreeMeta(specBase, scanRoot, changeName)
|
|
401
404
|
if (wt) {
|
|
@@ -429,7 +432,20 @@ function _resolveDiffFilesForParity(specBase, scanRoot, changeName) {
|
|
|
429
432
|
if (foreign.length > 0) {
|
|
430
433
|
console.warn(`⚠️ parity 对账已排除 ${foreign.length} 个并行会话声明的文件(${foreign.slice(0, 5).map(x => x.file).join(', ')}${foreign.length > 5 ? ' 等' : ''})`)
|
|
431
434
|
}
|
|
432
|
-
return own
|
|
435
|
+
if (own.length > 0) return own
|
|
436
|
+
// 主仓 diff 空(apply 后已 commit / cleanup 完毕)→ apply-pathspec 兜底:apply 成功时落盘的
|
|
437
|
+
// 本变更精确文件清单(与 gates 归因提示②同源)。仍拿不到才回 null(调用方退全仓)。
|
|
438
|
+
try {
|
|
439
|
+
const pathspecFile = join(runtimeRoot || join(specBase, '.runtime'), `apply-pathspec-${changeName}.txt`)
|
|
440
|
+
if (existsSync(pathspecFile)) {
|
|
441
|
+
const fromPathspec = readFileSync(pathspecFile, 'utf8').split('\n').map(l => l.trim()).filter(Boolean)
|
|
442
|
+
if (fromPathspec.length > 0) {
|
|
443
|
+
console.log(`ℹ️ parity 前端范围用 apply-pathspec 兜底(主仓 diff 为空,${fromPathspec.length} 文件)`)
|
|
444
|
+
return fromPathspec
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
} catch { /* 兜底失败 → null 交调用方全仓 */ }
|
|
448
|
+
return null
|
|
433
449
|
} catch { return null }
|
|
434
450
|
}
|
|
435
451
|
|
|
@@ -526,7 +542,7 @@ export function verifyApiParity(specBase, scanRoot, runtimeRoot, changeName = nu
|
|
|
526
542
|
const frontendRootLabel = frontendRoot === scanRoot ? 'scan-root' : 'worktree'
|
|
527
543
|
if (changeName) {
|
|
528
544
|
try {
|
|
529
|
-
const changed = _resolveDiffFilesForParity(specBase, scanRoot, changeName)
|
|
545
|
+
const changed = _resolveDiffFilesForParity(specBase, scanRoot, changeName, runtimeRoot)
|
|
530
546
|
if (changed && changed.length > 0) {
|
|
531
547
|
const changedSet = new Set(changed.map(f => f.replace(/\\/g, '/')))
|
|
532
548
|
frontendCalls = scanFrontendApiCalls(frontendRoot).filter(c => {
|
package/src/decision-distill.js
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* 零输出:无 decisions.md 或无入选条目 → skipped 带原因,不写任何文件、不动 INDEX。
|
|
23
23
|
*/
|
|
24
24
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
|
|
25
|
-
import { dirname, join } from 'node:path'
|
|
25
|
+
import { basename, dirname, join } from 'node:path'
|
|
26
26
|
|
|
27
27
|
/** FR-02:有实现影响的五类 type(scope 与未知 type 的 confirmed/accepted 不入选) */
|
|
28
28
|
const IMPLEMENTED_TYPES = new Set(['architecture', 'compatibility', 'boundary', 'definition', 'process'])
|
|
@@ -244,10 +244,14 @@ function newDomainPreamble(domain) {
|
|
|
244
244
|
]
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
-
/** 单条目段落渲染(design 接口定义的条目格式,逐字对齐:状态/锚点/最近确认/理由 + rejected
|
|
248
|
-
|
|
247
|
+
/** 单条目段落渲染(design 接口定义的条目格式,逐字对齐:状态/锚点/最近确认/理由 + rejected 专属两行)。
|
|
248
|
+
* 变更:行(2026-08-28,坑 distill-cross-change-supersede):D-xxx 编号是变更内局部序号,
|
|
249
|
+
* 跨变更同号(两个 D-002)互不相干——条目必须携带变更名限定,消费方(knowledge-match/
|
|
250
|
+
* docs-check 的字段行解析)只认各自标签,新增字段行是增量安全。 */
|
|
251
|
+
function renderBlockLines(entry, headHash, supersedesNote, changeName) {
|
|
249
252
|
const lines = [`## ${entry.id} ${oneLine(entry.title)}`.replace(/\s+$/, '')]
|
|
250
253
|
lines.push(`状态:${entry.selected}`)
|
|
254
|
+
if (changeName) lines.push(`变更:${oneLine(changeName)}`)
|
|
251
255
|
lines.push(`锚点:${oneLine(entry.anchor) || NOT_RECORDED}`)
|
|
252
256
|
lines.push(`最近确认:${oneLine(headHash) || NOT_RECORDED}`)
|
|
253
257
|
lines.push(`理由:${oneLine(entry.answer || entry.normalizedRequirement || entry.question)}`)
|
|
@@ -259,7 +263,9 @@ function renderBlockLines(entry, headHash, supersedesNote) {
|
|
|
259
263
|
return lines
|
|
260
264
|
}
|
|
261
265
|
|
|
262
|
-
/** 知识文件切段:preamble 行 + D-xxx@vN 条目段(段边界 = 下一 `## D-`
|
|
266
|
+
/** 知识文件切段:preamble 行 + D-xxx@vN 条目段(段边界 = 下一 `## D-` 头)。
|
|
267
|
+
* 段的变更归属取段内 `变更:<name>` 行;无该行的历史条目 → change=null(legacy:
|
|
268
|
+
* 升级前落库的条目,不参与新条目的同号匹配/supersede——只共存不误删)。 */
|
|
263
269
|
function splitKnowledgeSections(content) {
|
|
264
270
|
const sections = []
|
|
265
271
|
const preamble = []
|
|
@@ -275,7 +281,11 @@ function splitKnowledgeSections(content) {
|
|
|
275
281
|
else preamble.push(line)
|
|
276
282
|
}
|
|
277
283
|
if (cur) sections.push(cur)
|
|
278
|
-
for (const s of sections)
|
|
284
|
+
for (const s of sections) {
|
|
285
|
+
s.id = `${s.number}@v${s.version}`
|
|
286
|
+
const cm = s.lines.find(l => /^变更[::]/.test(l))
|
|
287
|
+
s.change = cm ? cm.replace(/^变更[::]\s*/, '').trim() || null : null
|
|
288
|
+
}
|
|
279
289
|
return { preamble, sections }
|
|
280
290
|
}
|
|
281
291
|
|
|
@@ -293,15 +303,20 @@ function joinKnowledgeFile(preamble, sections) {
|
|
|
293
303
|
|
|
294
304
|
/**
|
|
295
305
|
* 单条目写入一个域文件的段落集(就地修改 sections)。
|
|
296
|
-
*
|
|
306
|
+
* 坑 distill-cross-change-supersede(2026-08-28 用户实证:跨变更同号 D-002 在 knowledge
|
|
307
|
+
* 里互相 supersede):幂等键从「号」改为「号+变更」——同号匹配、版本守卫、旧版本段清除
|
|
308
|
+
* 全部限定在 entry.change 内;他者变更的同号段不参与(共存)。legacy 段(change=null,
|
|
309
|
+
* 升级前落库)不与任何新条目互认,只共存不误删。
|
|
310
|
+
* @returns {{ action: 'append'|'update'|'supersede' }|null} null = 同变更同号高版本已落库,迟到低版本不降级。
|
|
297
311
|
*/
|
|
298
312
|
function applyEntryToSections(sections, entry, headHash) {
|
|
299
|
-
const
|
|
313
|
+
const change = entry.change || null
|
|
314
|
+
const sameNumber = sections.filter(s => s.number === entry.number && (s.change || null) === change)
|
|
300
315
|
if (sameNumber.some(s => s.version > entry.version)) return null
|
|
301
316
|
let action
|
|
302
317
|
let supersedesNote = oneLine(entry.supersedes) || null
|
|
303
318
|
if (sameNumber.length === 0) {
|
|
304
|
-
sections.push({ number: entry.number, version: entry.version, id: entry.id, title: entry.title, lines: renderBlockLines(entry, headHash, supersedesNote) })
|
|
319
|
+
sections.push({ number: entry.number, version: entry.version, id: entry.id, title: entry.title, change, lines: renderBlockLines(entry, headHash, supersedesNote, change || undefined) })
|
|
305
320
|
action = 'append'
|
|
306
321
|
} else {
|
|
307
322
|
const same = sameNumber.find(s => s.version === entry.version)
|
|
@@ -309,10 +324,10 @@ function applyEntryToSections(sections, entry, headHash) {
|
|
|
309
324
|
if (!same && !supersedesNote) supersedesNote = `${slot.number}@v${slot.version}`
|
|
310
325
|
slot.version = entry.version
|
|
311
326
|
slot.title = entry.title
|
|
312
|
-
slot.lines = renderBlockLines(entry, headHash, supersedesNote)
|
|
313
|
-
//
|
|
327
|
+
slot.lines = renderBlockLines(entry, headHash, supersedesNote, change || undefined)
|
|
328
|
+
// 同变更同号旧版本段(< 新版本)就地清除——同文件内同变更同号只留一个段
|
|
314
329
|
for (let i = sections.length - 1; i >= 0; i--) {
|
|
315
|
-
if (sections[i].number === entry.number && sections[i] !== slot) sections.splice(i, 1)
|
|
330
|
+
if (sections[i].number === entry.number && (sections[i].change || null) === change && sections[i] !== slot) sections.splice(i, 1)
|
|
316
331
|
}
|
|
317
332
|
action = same ? 'update' : 'supersede'
|
|
318
333
|
}
|
|
@@ -412,6 +427,9 @@ function syncIndexRoutingLines(knowledgeRoot) {
|
|
|
412
427
|
* 缺否决理由/复潮条件被拦下(该条目未写盘,其余条目照常提炼)。
|
|
413
428
|
*/
|
|
414
429
|
export function distillIntoKnowledge(changeDir, knowledgeRoot, headHash, moduleIndex = null) {
|
|
430
|
+
// 变更名限定(坑 distill-cross-change-supersede):条目幂等键 = 号+变更。changeDir 末段
|
|
431
|
+
// 即变更名(changes/<name> 与 archive 后的 changes/archive/<name> 同为 <name>)。
|
|
432
|
+
const changeName = basename(changeDir)
|
|
415
433
|
const parsed = parseDecisions(changeDir)
|
|
416
434
|
if (parsed.missing) {
|
|
417
435
|
return { written: [], skipped: `decisions.md 不存在(${join(changeDir, 'decisions.md')}),零输出`, needsWait: null }
|
|
@@ -450,7 +468,7 @@ export function distillIntoKnowledge(changeDir, knowledgeRoot, headHash, moduleI
|
|
|
450
468
|
const taken = new Set()
|
|
451
469
|
for (const e of distillable) {
|
|
452
470
|
const keep = highestByNumber.get(e.number)
|
|
453
|
-
if (keep && !taken.has(e.number)) { taken.add(e.number); batch.push(keep) }
|
|
471
|
+
if (keep && !taken.has(e.number)) { taken.add(e.number); keep.change = changeName; batch.push(keep) }
|
|
454
472
|
}
|
|
455
473
|
|
|
456
474
|
const decisionsDir = join(knowledgeRoot, 'decisions')
|
|
@@ -489,7 +507,8 @@ export function distillIntoKnowledge(changeDir, knowledgeRoot, headHash, moduleI
|
|
|
489
507
|
}
|
|
490
508
|
}
|
|
491
509
|
|
|
492
|
-
//
|
|
510
|
+
// 跨文件清理:同变更同号 ≤ 新版本的旧段散落其他域文件时移除(同变更同号全局只留最高
|
|
511
|
+
// 版本/目标文件);他者变更的同号段不参与(共存,坑 distill-cross-change-supersede)
|
|
493
512
|
let onDisk = []
|
|
494
513
|
try { onDisk = readdirSync(decisionsDir).filter(f => f.endsWith('.md')).map(f => f.replace(/\.md$/, '')) } catch { /* 刚创建,走 store */ }
|
|
495
514
|
const allDomains = [...new Set([...onDisk, ...store.keys()])]
|
|
@@ -498,7 +517,7 @@ export function distillIntoKnowledge(changeDir, knowledgeRoot, headHash, moduleI
|
|
|
498
517
|
for (const d of allDomains) {
|
|
499
518
|
if (targets.has(d)) continue
|
|
500
519
|
const st = loadDomain(d)
|
|
501
|
-
const removed = st.sections.filter(s => s.number === entry.number && s.version <= entry.version)
|
|
520
|
+
const removed = st.sections.filter(s => s.number === entry.number && s.version <= entry.version && (s.change || null) === (entry.change || null))
|
|
502
521
|
if (removed.length === 0) continue
|
|
503
522
|
st.sections = st.sections.filter(s => !removed.includes(s))
|
|
504
523
|
st.dirty = true
|
package/src/docs-check.js
CHANGED
|
@@ -630,10 +630,20 @@ function extractKnownFailureKeys(yamlText) {
|
|
|
630
630
|
if (inline) {
|
|
631
631
|
return inline[1].split(',').map(s => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean)
|
|
632
632
|
}
|
|
633
|
-
|
|
633
|
+
// 坑 verify-known-failures-comment-line-truncation 同款(与 verify-postcheck.extractKnownFailures
|
|
634
|
+
// 口径互指对齐):块内注释行/空行打断连续列表项捕获链 → 注释后的键静默丢失。块定义放行
|
|
635
|
+
// 注释行与空行;提取侧只认 - 项行;行尾注释须 # 前有空白(裸 # 不截);引号值原样保留。
|
|
636
|
+
const block = yaml.match(/^known_failures:\s*\n((?:[ \t]+-[ \t].+\n?|[ \t]*#[^\n]*\n?|[ \t]*\n)+)/m)
|
|
634
637
|
if (block) {
|
|
635
638
|
return (block[1].match(/^[ \t]+-[ \t]+(.+)/gm) || [])
|
|
636
|
-
.map(s =>
|
|
639
|
+
.map(s => {
|
|
640
|
+
const item = s.replace(/^[ \t]+-[ \t]+/, '').trim()
|
|
641
|
+
const full = item.match(/^(['"])(.*)\1$/)
|
|
642
|
+
if (full) return full[2]
|
|
643
|
+
const stripped = item.replace(/[ \t]+#.*$/, '').trim()
|
|
644
|
+
const q2 = stripped.match(/^(['"])(.*)\1$/)
|
|
645
|
+
return q2 ? q2[2] : stripped
|
|
646
|
+
})
|
|
637
647
|
.filter(Boolean)
|
|
638
648
|
}
|
|
639
649
|
return []
|
|
@@ -823,3 +833,129 @@ export async function runDecisionRules(opts) {
|
|
|
823
833
|
exempted.sort((a, b) => a.key.localeCompare(b.key))
|
|
824
834
|
return { empty: false, domains: domainFiles.length, entries: entriesTotal, implemented: implementedTotal, threshold, findings, exempted }
|
|
825
835
|
}
|
|
836
|
+
|
|
837
|
+
// ---------------------------------------------------------------------------
|
|
838
|
+
// 变更名提名规则族(advisory,2026-08-31 变更关联审计 P2)
|
|
839
|
+
//
|
|
840
|
+
// 校验对象:文档正文里提名的变更名 token(`2026-NN-NN-<slug>`)。背景实测:变更间关联
|
|
841
|
+
// 的真实形态是散文提名(全仓 265 处他者提名,85% 属实质依赖/溯源/叙事),而非路径链接
|
|
842
|
+
// 或声明式字段;提名当前完全不受校验——拼错、归档改名(历史 3 单)后静默烂掉没人发现。
|
|
843
|
+
// 规则单一:提名的名字在「活跃 ∪ 归档」变更名单中存在;不存在 = 悬空。
|
|
844
|
+
// 铁律(与决策规则族同款,docs-consistency 模块卡):advisory 只 warn 不进 runDocsCheck
|
|
845
|
+
// 的 invalid 阻断链——ok/invalid 判定、exit code、docs gate 阻断行为均不受影响;只读零写盘。
|
|
846
|
+
// 豁免走 known_failures `change-name.<名字>` 键(decisions.* 同款机制):prompt 模板示例名、
|
|
847
|
+
// 跨仓变更名(repo:// 语义但散文书写)按名豁免,披露不隐藏。
|
|
848
|
+
// 结构化排除(非豁免,正则层面):
|
|
849
|
+
// - lookbehind [A-Za-z0-9-]:x- 前缀化合物不拆半名(review-2026-08-08.md 文档名、
|
|
850
|
+
// brainstorm-review-2026-08-23-205426 run-id 时间戳、双日期前缀目录名整体不命中);
|
|
851
|
+
// - slug 字母开头:纯数字 slug(HHMMSS 时间戳类)不命中;
|
|
852
|
+
// - ql-* quicklog id 不是变更目录名,天然不命中(quick 关联另有 CLI 悬空守卫)。
|
|
853
|
+
// ---------------------------------------------------------------------------
|
|
854
|
+
|
|
855
|
+
/** 缺省扫描集:主链路 docs 两侧 + 变更文档。强制 skip 追加 archive/(归档文档是冻结
|
|
856
|
+
* 历史事实,当时提名不追改)与 docs/prompt/(agent 指令模板,示例变更名 by-design 虚构) */
|
|
857
|
+
export const CHANGE_NAME_DEFAULT_PATHS = ['docs/**/*.md', '.sillyspec/docs/**/*.md', '.sillyspec/changes/**/*.md']
|
|
858
|
+
const CHANGE_NAME_FORCED_SKIP = ['.sillyspec/changes/archive', 'docs/prompt']
|
|
859
|
+
|
|
860
|
+
/** 变更名 token:日期前缀 + 字母开头 slug(≥3 字符) */
|
|
861
|
+
const CHANGE_NAME_TOKEN_RE = /(?<![A-Za-z0-9-])(\d{4}-\d{2}-\d{2}-[a-z][a-z0-9-]{2,})/g
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* 纯函数:提取文本里的变更名提名 token(matchAll 对共享 /g 正则内部克隆,无 lastIndex 竞态)。
|
|
865
|
+
* 紧随 `.md` 的 token 排除——那是文件名提及(.sillyspec/plans/ 计划快照、他仓 docs 路径),
|
|
866
|
+
* 不是变更目录提名(目录提名后随 `/` 或独立成词)。
|
|
867
|
+
* @param {string} text 行文本或全文
|
|
868
|
+
* @returns {string[]} 名字列表(含重复,调用方自行聚合计数)
|
|
869
|
+
*/
|
|
870
|
+
export function collectChangeNameTokens(text) {
|
|
871
|
+
const md = String(text || '')
|
|
872
|
+
return [...md.matchAll(CHANGE_NAME_TOKEN_RE)]
|
|
873
|
+
.filter(m => md.slice(m.index + m[0].length, m.index + m[0].length + 3).toLowerCase() !== '.md')
|
|
874
|
+
.map(m => m[1])
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* 变更名单收集:活跃目录 ∪ 归档目录(+ 调用方注入额 extraNames,如 DB 名单——DB 与目录
|
|
879
|
+
* 历史上曾分叉,并集口径防假阳性)。目录缺失 → 跳过不抛。
|
|
880
|
+
* @param {string} specBase 规范根(<root>/.sillyspec 或平台 specRoot)
|
|
881
|
+
* @param {string[]} [extraNames]
|
|
882
|
+
* @returns {Set<string>}
|
|
883
|
+
*/
|
|
884
|
+
export function collectKnownChangeNames(specBase, extraNames = []) {
|
|
885
|
+
const names = new Set()
|
|
886
|
+
for (const sub of ['changes', join('changes', 'archive')]) {
|
|
887
|
+
try {
|
|
888
|
+
for (const e of readdirSync(join(specBase, sub), { withFileTypes: true })) {
|
|
889
|
+
if (e.isDirectory()) names.add(e.name)
|
|
890
|
+
}
|
|
891
|
+
} catch { /* 目录缺失 → 跳过 */ }
|
|
892
|
+
}
|
|
893
|
+
for (const n of extraNames) if (n && typeof n === 'string') names.add(n)
|
|
894
|
+
return names
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* IO 入口(advisory):扫文档集报告悬空的变更名提名。不进 invalid 阻断链、不写盘。
|
|
899
|
+
* @param {{ projectRoot: string, specBase?: string|null, paths?: string[]|null, skip?: string[]|null,
|
|
900
|
+
* knownFailures?: string[]|null, extraNames?: string[] }} opts
|
|
901
|
+
* paths 缺省 CHANGE_NAME_DEFAULT_PATHS(与主链路不同:多扫 .sillyspec/changes/——变更
|
|
902
|
+
* design/proposal 是实质依赖提名的主阵地);skip 缺省读 local.yaml 并强制追加
|
|
903
|
+
* .sillyspec/changes/archive;knownFailures 缺省读 local.yaml known_failures
|
|
904
|
+
* @returns {{ empty: boolean, scannedDocs: number, mentions: number, uniqueNames: number,
|
|
905
|
+
* findings: Array<{ doc: string, docLine: number, name: string, message: string }>,
|
|
906
|
+
* exempted: Array<{ key: string, name: string }> }}
|
|
907
|
+
*/
|
|
908
|
+
export function runChangeNameAdvisory(opts) {
|
|
909
|
+
const { projectRoot, specBase = null, paths = null, skip = null, knownFailures = null, extraNames = [] } = opts || {}
|
|
910
|
+
const emptyResult = { empty: true, scannedDocs: 0, mentions: 0, uniqueNames: 0, findings: [], exempted: [] }
|
|
911
|
+
if (!projectRoot) return emptyResult
|
|
912
|
+
|
|
913
|
+
const cfg = readDocsCheckConfig(projectRoot)
|
|
914
|
+
const globs = paths || cfg.paths || CHANGE_NAME_DEFAULT_PATHS
|
|
915
|
+
const skipList = [...(skip || cfg.skip || []), ...CHANGE_NAME_FORCED_SKIP]
|
|
916
|
+
let failures = knownFailures
|
|
917
|
+
if (!Array.isArray(failures)) {
|
|
918
|
+
try {
|
|
919
|
+
failures = extractKnownFailureKeys(readFileSync(join(projectRoot, '.sillyspec', 'local.yaml'), 'utf8'))
|
|
920
|
+
} catch { failures = [] }
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// 名单口径:本仓活跃∪归档 ∪ 调用方注入(如 DB 名)∪ 跨仓根(docs-check.cross_repo_roots
|
|
924
|
+
// 已配置的兄弟仓——散文跨仓提名无 repo:// 标记,出现在任一已配置根的变更名录即有效;
|
|
925
|
+
// 未配置根不参与,与 repo:// 行号校验的「未配映射跳过」同语义)
|
|
926
|
+
const known = collectKnownChangeNames(specBase || join(projectRoot, '.sillyspec'), extraNames)
|
|
927
|
+
for (const root of Object.values(cfg.crossRepoRoots || {})) {
|
|
928
|
+
for (const n of collectKnownChangeNames(join(root, '.sillyspec'))) known.add(n)
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
const docs = new Set()
|
|
932
|
+
for (const g of globs) for (const rel of walkGlob(projectRoot, g, skipList)) docs.add(rel)
|
|
933
|
+
|
|
934
|
+
const findings = []
|
|
935
|
+
const exempted = []
|
|
936
|
+
const seenNames = new Set()
|
|
937
|
+
let mentions = 0
|
|
938
|
+
let scanned = 0
|
|
939
|
+
for (const rel of [...docs].sort()) {
|
|
940
|
+
let lines
|
|
941
|
+
try { lines = readFileSync(join(projectRoot, rel), 'utf8').replace(/\r\n?/g, '\n').split('\n') } catch { continue }
|
|
942
|
+
scanned++
|
|
943
|
+
lines.forEach((line, i) => {
|
|
944
|
+
for (const name of collectChangeNameTokens(line)) {
|
|
945
|
+
mentions++
|
|
946
|
+
seenNames.add(name)
|
|
947
|
+
if (known.has(name)) continue
|
|
948
|
+
if (failures.includes(`change-name.${name}`)) {
|
|
949
|
+
exempted.push({ key: `change-name.${name}`, name })
|
|
950
|
+
continue
|
|
951
|
+
}
|
|
952
|
+
findings.push({
|
|
953
|
+
doc: rel, docLine: i + 1, name,
|
|
954
|
+
message: `[${rel}:L${i + 1}] 提名「${name}」在活跃∪归档变更名单中不存在——拼错 / 改名未同步 / 他仓变更?确认后修正,或在 local.yaml known_failures 加 "change-name.${name}" 豁免(advisory 不阻断)`,
|
|
955
|
+
})
|
|
956
|
+
}
|
|
957
|
+
})
|
|
958
|
+
}
|
|
959
|
+
findings.sort((a, b) => a.doc.localeCompare(b.doc) || a.docLine - b.docLine || a.name.localeCompare(b.name))
|
|
960
|
+
return { empty: scanned === 0, scannedDocs: scanned, mentions, uniqueNames: seenNames.size, findings, exempted }
|
|
961
|
+
}
|
|
@@ -47,8 +47,10 @@ export function extractFastApiEndpoints(filePath) {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
// 2. 装饰器全文匹配:单行 `@router.get("/path")`、分散式 `@router.get\n("/path")`、
|
|
50
|
-
// 多行参数 `@router.get(\n "/path",\n response_model=…)` 三态合一(\s*
|
|
51
|
-
|
|
50
|
+
// 多行参数 `@router.get(\n "/path",\n response_model=…)` 三态合一(\s* 跨换行)。
|
|
51
|
+
// 路径组 `[^"'`]*` 允许空串(坑 endpoints-extract-worktree-pitfalls ③:`@router.get("")`
|
|
52
|
+
// 空路径装饰器 = 前缀本身即路由,`+` 量词静默漏扫——GET /notifications 实证丢失)。
|
|
53
|
+
const decoratorRe = /@(?:router|api_router)\.(get|post|put|delete|patch)\s*\(\s*["'`]([^"'`]*)["'`]/g
|
|
52
54
|
let m
|
|
53
55
|
while ((m = decoratorRe.exec(content)) !== null) {
|
|
54
56
|
endpoints.push({
|
|
@@ -82,8 +84,9 @@ export function extractExpressEndpoints(filePath) {
|
|
|
82
84
|
if (useMatch) routerPrefix = useMatch[1]
|
|
83
85
|
}
|
|
84
86
|
|
|
85
|
-
// 路由全文匹配(坑 endpoint-multiline-decorator-miss:`router.get(\n "/path",\n handler)`
|
|
86
|
-
|
|
87
|
+
// 路由全文匹配(坑 endpoint-multiline-decorator-miss:`router.get(\n "/path",\n handler)` 同病;
|
|
88
|
+
// 空串路径与 FastAPI 同理允许——`router.get("")` = use 前缀本身)
|
|
89
|
+
const routeRe = /\b(?:app|router)\.(get|post|put|delete|patch)\s*\(\s*["'`]([^"'`]*)["'`]/gi
|
|
87
90
|
let m
|
|
88
91
|
while ((m = routeRe.exec(content)) !== null) {
|
|
89
92
|
endpoints.push({ method: m[1].toUpperCase(), path: routerPrefix + m[2], source: filePath, line: lineOfIndex(content, m.index) })
|