sillyspec 3.29.5 → 3.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/sillyspec-auto/SKILL.md +1 -1
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +2 -2
- package/.claude/skills/sillyspec-commit/SKILL.md +1 -1
- package/.claude/skills/sillyspec-flow/SKILL.md +57 -0
- package/.claude/skills/sillyspec-quick/SKILL.md +7 -105
- package/AGENTS.md +38 -20
- package/SKILL.md +9 -3
- package/assets/command-cards/flow.md +32 -0
- package/assets/command-cards/run-brainstorm.md +1 -1
- package/assets/command-cards/run-quick.md +8 -25
- package/package.json +2 -1
- package/packages/dashboard/server/executor.js +2 -1
- package/packages/dashboard/server/parser.js +7 -7
- package/r7-test-env/README-R7-TEST.md +93 -0
- package/r7-test-env/demo-project/lib/calc.js +6 -0
- package/r7-test-env/demo-project/package.json +1 -0
- package/r7-test-env/demo-project/test.js +10 -0
- package/r7-test-env/prompt-R7-SMOKE.md +41 -0
- package/r7-test-env/sillyspec-r7.cmd +3 -0
- package/round4/account.mjs +18 -5
- package/round4/b1-analyze-task08.mjs +40 -0
- package/round4/b1-b1-target-validation.md +32 -0
- package/round4/b3a-prompt-audit-result.md +66 -0
- package/round4/b3a-prompt-audit.mjs +89 -0
- package/round4/optimization-plan.md +165 -0
- package/round4/r4-final-report.html +347 -0
- package/round4/r5-scenario-matrix.md +46 -0
- package/src/change-list.js +9 -0
- package/src/classify-change.js +6 -0
- package/src/command-cards.js +2 -1
- package/src/commit-guard.js +2 -0
- package/src/config-schema.js +64 -12
- package/src/db.js +35 -4
- package/src/decision-distill.js +9 -3
- package/src/docs-check.js +1 -1
- package/src/doctor-diagnostics.js +44 -2
- package/src/flow-draft.js +748 -0
- package/src/flow-parity.js +236 -0
- package/src/flow-review.js +184 -0
- package/src/flow.js +1492 -0
- package/src/foreign-declared.js +17 -1
- package/src/fr-index.js +396 -22
- package/src/fs-atomic.js +5 -1
- package/src/git-helper.js +2 -2
- package/src/greenfield-bootstrap.js +98 -0
- package/src/handoff.js +120 -7
- package/src/hunk-attribution.js +191 -0
- package/src/index.js +570 -16
- package/src/knowledge-classify.js +47 -1
- package/src/knowledge-digest.js +162 -0
- package/src/knowledge-quicklog.js +246 -0
- package/src/machine-draft.js +128 -0
- package/src/machine-interface.js +107 -5
- package/src/mcp-server.js +1 -0
- package/src/module-resolve.js +3 -1
- package/src/preview-progress.js +160 -0
- package/src/progress/change-registry.js +11 -3
- package/src/progress/shared.js +3 -2
- package/src/progress.js +56 -13
- package/src/quicklog.js +44 -6
- package/src/redomain.js +147 -0
- package/src/review-material-pack.js +204 -1
- package/src/run/archive-distill.js +4 -2
- package/src/run/bg-sync.js +67 -0
- package/src/run/command.js +123 -10
- package/src/run/complete-handlers.js +327 -72
- package/src/run/complete.js +350 -71
- package/src/run/gate-snapshot-ledger.js +213 -0
- package/src/run/gate-snapshot.js +250 -34
- package/src/run/gates.js +370 -233
- package/src/run/green-cache.js +123 -0
- package/src/run/junction-rm.js +70 -0
- package/src/run/next.js +14 -0
- package/src/run/prompt.js +185 -55
- package/src/run/quick-audit.js +185 -12
- package/src/run/shared.js +120 -3
- package/src/run/stage.js +182 -216
- package/src/run/test-ledger.js +264 -0
- package/src/run/verify-quality-scan.js +84 -7
- package/src/scope-audit.js +6 -1
- package/src/sentinel-assertions.js +114 -0
- package/src/setup.js +3 -3
- package/src/spec-sync.js +41 -1
- package/src/stage-contract.js +26 -8
- package/src/stage-review-checklist.js +2 -0
- package/src/stage-review.js +7 -2
- package/src/stage-templates.js +157 -0
- package/src/stages/archive.js +2 -0
- package/src/stages/brainstorm.js +24 -106
- package/src/stages/execute.js +221 -84
- package/src/stages/knowledge.js +74 -1
- package/src/stages/plan-postcheck.js +352 -1
- package/src/stages/plan.js +23 -1
- package/src/stages/quick.js +10 -2
- package/src/stages/verify.js +4 -2
- package/src/sync.js +81 -23
- package/src/task-done.js +181 -0
- package/src/task-review.js +8 -55
- package/src/test-bindings.js +549 -0
- package/src/timeline.js +218 -0
- package/src/ui-visual.js +175 -0
- package/src/verify-draft.js +214 -0
- package/src/verify-postcheck.js +860 -158
- package/src/verify-probes.js +59 -3
- package/src/watcher.js +1031 -0
- package/src/worktree-apply.js +44 -4
- package/src/worktree-deps.js +22 -8
- package/src/worktree-policy.js +42 -0
- package/src/worktree.js +102 -15
- package/templates/agents-instruction.md +37 -18
package/src/timeline.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* timeline.js — 变更合成时间线(2026-09-26-watcher-timeline)。
|
|
3
|
+
*
|
|
4
|
+
* 只读合成器:watcher 事件流(jsonl)× tasks.md 勾选行 × git 提交锚 → 人类可读时间线。
|
|
5
|
+
* 轻量变更无阶段 --done 步骤面(平台 UI 时间线只覆盖完整流程),本模块把既有留痕事后
|
|
6
|
+
* 合成——零新落盘、零协议负担。CLI 出口:sillyspec watcher timeline --change <名>
|
|
7
|
+
* (index.js watcher case,与 alerts 同族只读)。
|
|
8
|
+
*
|
|
9
|
+
* 纯度口径:本文件全部纯函数(git 经 gitLook 注入面,测试零真仓);I/O 与退出码归接线层。
|
|
10
|
+
* 诚实面(FR-04):事件流 task-done 只记计数不记任务 id——勾选时刻按翻格顺序推断,表头
|
|
11
|
+
* 显式「≈」,计数链断裂标「推断不可用」;提交 hash 不可解析只显 hash 并标注。
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync } from 'fs';
|
|
14
|
+
import { join } from 'path';
|
|
15
|
+
import { aggregateStageTiming } from './watcher.js';
|
|
16
|
+
|
|
17
|
+
/** tasks.md 勾选行解析:`- [x] task-01: 描述` / `- [ ] task-02 无冒号描述`。 */
|
|
18
|
+
export function parseTaskLines(tasksMd) {
|
|
19
|
+
const out = [];
|
|
20
|
+
for (const line of String(tasksMd || '').split(/\r?\n/)) {
|
|
21
|
+
const m = /^[-*] \[( |x|X)\] (task-\d+):?[ \t]*(.*)$/.exec(line);
|
|
22
|
+
if (m) out.push({ id: m[2], checked: m[1].toLowerCase() === 'x', desc: m[3].trim() });
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 翻格顺序推断(FR-04):task-done(stage='tasks')事件序列 → 每任务序号的勾选时刻。
|
|
29
|
+
* 计数链衔接(from === 游标)才赋值;**中段**断裂(from ≠ 游标)标 broken 并停止赋值。
|
|
30
|
+
* 尾部未覆盖(链完整但拍数少于任务数——在途变更未勾完)不标 broken(2026-09-26-watcher-timeline-p2
|
|
31
|
+
* 语义收窄:那是自然态不是断裂;「已勾任务缺时刻」由渲染层按事实单独标注)。
|
|
32
|
+
* @returns {{times:(number|null)[], broken:boolean}} times 按任务序号索引;未推断到 = null。
|
|
33
|
+
*/
|
|
34
|
+
export function inferFlipTimes(events, total) {
|
|
35
|
+
const times = new Array(Math.max(0, total || 0)).fill(null);
|
|
36
|
+
let cursor = 0;
|
|
37
|
+
let broken = false;
|
|
38
|
+
for (const e of events || []) {
|
|
39
|
+
if (!e || e.kind !== 'task-done' || (e.stage && e.stage !== 'tasks')) continue;
|
|
40
|
+
const m = /^checked (\d+)→(\d+)$/.exec(String(e.detail || ''));
|
|
41
|
+
if (!m) continue;
|
|
42
|
+
const from = Number(m[1]);
|
|
43
|
+
const to = Number(m[2]);
|
|
44
|
+
if (from !== cursor) { broken = true; break; }
|
|
45
|
+
for (let i = from; i < to && i < times.length; i++) times[i] = e.ts;
|
|
46
|
+
cursor = to;
|
|
47
|
+
}
|
|
48
|
+
return { times, broken };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 提交消息里的 task token 清单(去重保序)。 */
|
|
52
|
+
function tokensOf(text) {
|
|
53
|
+
const out = [];
|
|
54
|
+
for (const t of String(text || '').match(/task-\d+(?!\d)/g) || []) {
|
|
55
|
+
if (!out.includes(t)) out.push(t);
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 提交锚解析:事件流 kind=commit 的短 hash → gitLook 解析出主题/时间/token。
|
|
62
|
+
* @param {Function} gitLook (hash) => null | {hash, dateISO, message}(注入面,接线层包 gitQuiet)
|
|
63
|
+
* @returns {Array<{hash, ts, subject, tokens, resolvable}>} ts 取事件观测时刻(git 日期仅存证)。
|
|
64
|
+
*/
|
|
65
|
+
export function resolveCommitAnchors(events, gitLook) {
|
|
66
|
+
const out = [];
|
|
67
|
+
for (const e of events || []) {
|
|
68
|
+
if (!e || e.kind !== 'commit' || !e.detail) continue;
|
|
69
|
+
const hash = String(e.detail).trim();
|
|
70
|
+
let info = null;
|
|
71
|
+
try { info = gitLook ? gitLook(hash) : null; } catch { info = null; }
|
|
72
|
+
out.push({
|
|
73
|
+
hash,
|
|
74
|
+
ts: e.ts,
|
|
75
|
+
subject: info ? String(info.message || '').split(/\r?\n/)[0] : null,
|
|
76
|
+
tokens: info ? tokensOf(info.message) : [],
|
|
77
|
+
resolvable: Boolean(info),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 本地 HH:MM:SS(与 watcher alerts fmtLocal 同风格,供渲染层一致时间面)。 */
|
|
84
|
+
function fmtHMS(ts) {
|
|
85
|
+
const d = new Date(Number(ts));
|
|
86
|
+
if (Number.isNaN(d.getTime())) return String(ts);
|
|
87
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
88
|
+
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const clip = (s, n) => {
|
|
92
|
+
const t = String(s ?? '');
|
|
93
|
+
return t.length > n ? t.slice(0, Math.max(0, n - 1)) + '…' : t;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** 事件 → 时间轴行文本(图标+标签+剪裁 detail;提交行拼 subject)。 */
|
|
97
|
+
function eventRow(e, anchors) {
|
|
98
|
+
const icon = { file: '📄', 'file-update': '📝', 'task-done': '✅', commit: '🔀', verify: '🔬', warning: '⚠️', archived: '📦' }[e.kind] || '·';
|
|
99
|
+
let label = e.detail || '';
|
|
100
|
+
if (e.kind === 'commit') {
|
|
101
|
+
const a = anchors.find((x) => x.hash === String(e.detail).trim());
|
|
102
|
+
label = a && a.resolvable
|
|
103
|
+
? `${e.detail} ${clip(a.subject, 56)}`
|
|
104
|
+
: `${e.detail}(subject 不可解析——hash 失联/他仓)`;
|
|
105
|
+
} else if (e.kind === 'warning') {
|
|
106
|
+
label = `${e.rule || 'warning'} ${clip(e.detail, 64)}`;
|
|
107
|
+
} else {
|
|
108
|
+
label = clip(e.detail, 72);
|
|
109
|
+
}
|
|
110
|
+
return `${fmtHMS(e.ts)} ${icon} ${label}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 秒数 → 人类可读时长(<60s 秒、<60h 分、以上时分)。 */
|
|
114
|
+
function fmtDur(ms) {
|
|
115
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
116
|
+
if (s < 60) return `${s}s`;
|
|
117
|
+
const m = Math.floor(s / 60);
|
|
118
|
+
if (m < 60) return `${m}min`;
|
|
119
|
+
return `${Math.floor(m / 60)}h${m % 60}min`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 合成渲染(纯函数,接线层 console.log)。
|
|
124
|
+
* @param {object} args
|
|
125
|
+
* @param {string} args.change 变更名
|
|
126
|
+
* @param {Array} args.events 事件流(readWatcherEvents.events)
|
|
127
|
+
* @param {Array|null} args.tasks parseTaskLines 产物(null=任务面缺失)
|
|
128
|
+
* @param {Array} args.anchors resolveCommitAnchors 产物
|
|
129
|
+
* @param {number|null} args.birthTs 诞生锚(工件 frontmatter created_at 毫秒;null=缺)
|
|
130
|
+
* @param {string|null} args.tier flow-state tier(null=缺/未知)
|
|
131
|
+
*/
|
|
132
|
+
export function renderTimeline({ change, events, tasks, anchors, birthTs, tier }) {
|
|
133
|
+
const L = [];
|
|
134
|
+
L.push(`📅 ${change} — 合成时间线(${tier || 'tier 未知'}|事件流 × tasks.md × git 提交锚)`);
|
|
135
|
+
L.push('');
|
|
136
|
+
L.push('── 事件时间轴 ──');
|
|
137
|
+
if (birthTs != null) L.push(`${fmtHMS(birthTs)} 🁢 变更诞生(工件 frontmatter created_at)`);
|
|
138
|
+
if (!events || events.length === 0) L.push('(无事件——watcher 未观测到活动)');
|
|
139
|
+
for (const e of events || []) L.push(eventRow(e, anchors));
|
|
140
|
+
|
|
141
|
+
if (tasks) {
|
|
142
|
+
L.push('');
|
|
143
|
+
L.push('── 任务面(勾选时刻 ≈ 顺序推断|tasks.md 描述行 × 提交锚)──');
|
|
144
|
+
const { times, broken } = inferFlipTimes(events, tasks.length);
|
|
145
|
+
if (broken) L.push('(计数链中段断裂——断裂点后勾选时刻推断不可用,标 ?)');
|
|
146
|
+
if (tasks.some((t, i) => t.checked && times[i] == null)) L.push('(已勾任务缺推断时刻——观测盲窗/水位丢失,标 ?)');
|
|
147
|
+
for (let i = 0; i < tasks.length; i++) {
|
|
148
|
+
const t = tasks[i];
|
|
149
|
+
const when = t.checked
|
|
150
|
+
? (times[i] != null ? `≈${fmtHMS(times[i])}` : '?')
|
|
151
|
+
: '未勾';
|
|
152
|
+
const anchor = anchors.filter((a) => a.tokens.includes(t.id)).sort((a, b) => a.ts - b.ts)[0];
|
|
153
|
+
const ev = anchor ? anchor.hash : (t.checked ? '无提交锚⚠️' : '—');
|
|
154
|
+
L.push(`${t.id} ${when.padEnd(10)} ${clip(t.desc, 44).padEnd(46)}${ev}`);
|
|
155
|
+
}
|
|
156
|
+
} else {
|
|
157
|
+
L.push('');
|
|
158
|
+
L.push('── 任务面缺失(change 目录无 tasks.md——活跃/归档双路径均未命中)──');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const evN = (events || []).length;
|
|
162
|
+
const commits = anchors.length;
|
|
163
|
+
const lastTs = evN > 0 ? events[events.length - 1].ts : birthTs;
|
|
164
|
+
const wall = birthTs != null && lastTs != null ? Math.max(0, lastTs - birthTs) : null;
|
|
165
|
+
const done = tasks ? tasks.filter((t) => t.checked).length : null;
|
|
166
|
+
const stages = aggregateStageTiming(events || []);
|
|
167
|
+
L.push('');
|
|
168
|
+
L.push(`墙钟:${wall != null ? fmtDur(wall) : '未知'}|事件 ${evN} 条|提交 ${commits}|${tasks ? `任务 ${done}/${tasks.length} 勾选` : '任务面缺失'}`);
|
|
169
|
+
if (stages.length > 0) {
|
|
170
|
+
L.push(`阶段墙钟:${stages.map((s) => `${s.stage} ${fmtDur(s.durationMs)}`).join('|')}`);
|
|
171
|
+
}
|
|
172
|
+
L.push('注:观测起点≠诞生时刻(watcher 后拉起/单飞锁盲窗);勾选时刻为顺序推断(事件不记任务 id);描述行含机器稿截断。');
|
|
173
|
+
return L.join('\n');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 归档烤制文本(2026-09-28-archive-timeline-bake):快照头注记 + renderTimeline 全文。
|
|
178
|
+
* 事件流机本位(.runtime gitignore),本快照随归档包进 git 跨机可读;watcher 终拍可能晚于
|
|
179
|
+
* 烤制落盘——诚实注记而非数据错误。eventsCopySkipped=副本未随包形态(尺寸超帽或读源失败,
|
|
180
|
+
* 评审 P3-1:注记必须与实际产出一致,不虚报副本在场)。
|
|
181
|
+
*/
|
|
182
|
+
export function renderBakedTimeline({ change, events, tasks, anchors, birthTs, tier, bakedAtIso, eventsCopySkipped = false }) {
|
|
183
|
+
const head = [
|
|
184
|
+
`# 合成时间线快照 — ${change}`,
|
|
185
|
+
'',
|
|
186
|
+
`> 烤制于归档链(${bakedAtIso}):事件流机本位(.runtime gitignore),本快照随归档包进 git 跨机可读。`,
|
|
187
|
+
'> 末尾事件(含「目录已移入 archive」终拍)可能晚于烤制未入快照;勾选时刻为顺序推断(≈)。',
|
|
188
|
+
eventsCopySkipped
|
|
189
|
+
? '> 原始事件副本未随包(尺寸超帽或读源失败)——细粒度复核回本机 .runtime 或平台 observation 事件面。'
|
|
190
|
+
: '> 原始事件副本:本目录 watcher-events.jsonl(时点快照)。',
|
|
191
|
+
'',
|
|
192
|
+
]
|
|
193
|
+
return head.join('\n') + renderTimeline({ change, events, tasks, anchors, birthTs, tier })
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** change 目录双路径探测(活跃 > 归档)+ tasks.md 读取 → {changeDir, tasksMd}(缺 tasksMd=null)。 */
|
|
197
|
+
export function loadChangeTasks(specBase, change) {
|
|
198
|
+
const active = join(specBase, 'changes', change);
|
|
199
|
+
const archived = join(specBase, 'changes', 'archive', change);
|
|
200
|
+
const dir = [active, archived].find((d) => { try { return readFileSync(join(d, 'tasks.md'), 'utf8') != null } catch { return false } });
|
|
201
|
+
if (!dir) return { changeDir: null, tasksMd: null };
|
|
202
|
+
return { changeDir: dir, tasksMd: readFileSync(join(dir, 'tasks.md'), 'utf8') };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** 诞生锚:change 目录工件 frontmatter created_at(requirements > proposal > design > tasks)。 */
|
|
206
|
+
export function readBirthTs(changeDir) {
|
|
207
|
+
if (!changeDir) return null;
|
|
208
|
+
for (const f of ['requirements.md', 'proposal.md', 'design.md', 'tasks.md']) {
|
|
209
|
+
try {
|
|
210
|
+
const m = /^created_at:\s*(\S+)/m.exec(readFileSync(join(changeDir, f), 'utf8'));
|
|
211
|
+
const t = m ? Date.parse(m[1]) : Number.NaN;
|
|
212
|
+
if (Number.isFinite(t)) return t;
|
|
213
|
+
} catch { /* 缺件续查 */ }
|
|
214
|
+
}
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export default { parseTaskLines, inferFlipTimes, resolveCommitAnchors, renderTimeline, renderBakedTimeline, loadChangeTasks, readBirthTs };
|
package/src/ui-visual.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ui-visual —— UI 视觉变更的「过程引导 + 收口痕迹对账」(2026-09-27-ui-visual-guidance)。
|
|
3
|
+
*
|
|
4
|
+
* 背景教训(平台仓 2026-09-26-core-pages-visual-redesign 实证):视觉保真度在流程里零承接
|
|
5
|
+
* ——design 写明截图走查硬门,verify 以「代码级佐证」降级放行仍 PASS,差距部署后才被肉眼
|
|
6
|
+
* 发现。本模块补两件事(收口不产新证据,只验在场):
|
|
7
|
+
* ① flow start 引导前置:检测 --input 触及前端页面或 UI → 输出「UI 变更执行须知」
|
|
8
|
+
* (改前确认视觉基准、边改边渲染对照、证据随手落变更目录 visual-evidence.md、
|
|
9
|
+
* 视觉降级须用户裁决留痕)——agent 开工即知规程,不是收口才被告知;
|
|
10
|
+
* ② verify「UI 视觉证据」分级探针:UI 触达变更收口时验证据在场性——
|
|
11
|
+
* 缺 visual-evidence.md → 默认 ⚠️ 警告(local.yaml ui_visual_gate: warn|error|off,
|
|
12
|
+
* 默认 warn);design/requirements 声明视觉降级而无用户裁决留痕 → 恒 ❌ error
|
|
13
|
+
* (仅 off 豁免)——堵 D-004 型静默降级。
|
|
14
|
+
*
|
|
15
|
+
* 仓中立:不硬编码任何特定仓的路径或命令(须知指引通用口径:原型、黄金页或现有截图均可为基准)。
|
|
16
|
+
* 检测为纯函数;探针只读变更目录内文件,fail-soft 同探针 8-11 先例。
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
19
|
+
import { join } from 'node:path'
|
|
20
|
+
|
|
21
|
+
export const UI_VISUAL_PROBE_HEADING = '#### 探针 12:UI 视觉证据(分级门)'
|
|
22
|
+
export const UI_EVIDENCE_FILENAME = 'visual-evidence.md'
|
|
23
|
+
|
|
24
|
+
/* ── 检测启发式(正反例单测锁定;误判面=默认档一行警告,误漏面由文件面扩展名兜底)── */
|
|
25
|
+
|
|
26
|
+
const UI_TOUCH_PATTERNS = [
|
|
27
|
+
/页面/, /前端/, /\bUI\b/i, /视觉/, /样式/, /界面/, /布局/, /主题色/, /配色/,
|
|
28
|
+
/\.tsx?\b/, /\.jsx\b/, /\.vue\b/, /\.svelte\b/, /\.css\b/, /\.scss\b/, /\.html\b/,
|
|
29
|
+
/组件/, /原型/, /截图/, /渲染/,
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
/** input/规约文本是否触及前端页面或 UI(关键词启发式,≥1 命中即算)。 */
|
|
33
|
+
export function detectUiTouch(text) {
|
|
34
|
+
if (!text || typeof text !== 'string') return false
|
|
35
|
+
return UI_TOUCH_PATTERNS.some((re) => re.test(text))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 声明文件面是否含前端产物文件(design 清单路径扩展名口径——input 漏检的兜底源)。 */
|
|
39
|
+
export function detectUiTouchInPaths(paths) {
|
|
40
|
+
if (!Array.isArray(paths)) return false
|
|
41
|
+
return paths.some((p) =>
|
|
42
|
+
/\.(tsx|jsx|vue|svelte|css|scss|less|html)$/i.test(String(p).replace(/^NEW:\s*/, '')),
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* ── flow start 须知(仓中立;证据约定与探针同源)── */
|
|
47
|
+
|
|
48
|
+
export function buildUiGuidanceLines() {
|
|
49
|
+
return [
|
|
50
|
+
`🎨 UI 变更执行须知(本变更触及前端页面或 UI——检测命中即插入,advisory 引导非门禁):`,
|
|
51
|
+
` 1. 改前确认视觉基准:原型产物、黄金页或该页现有截图——没有基准的「改好看」无法验收;`,
|
|
52
|
+
` 2. 边改边渲染对照:每完成一个页面任务单元就渲染实页与基准并排比对,差异清零再勾任务;`,
|
|
53
|
+
` 3. 证据随手落盘:对照截图与结论逐页记入变更目录 ${UI_EVIDENCE_FILENAME}(干活时顺手写,收口只验在场);`,
|
|
54
|
+
` 4. 视觉降级须用户裁决留痕:执行中因约束对不齐基准而降级(结构收敛、范围缩小、样式统一级)`,
|
|
55
|
+
` 必须带对比截图停下找用户签字,裁决记录写进 ${UI_EVIDENCE_FILENAME}(含「用户裁决」字样段)——`,
|
|
56
|
+
` 静默降级会在收口被「UI 视觉证据」探针硬拦(error 门)。`,
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* ── 分级探针 ── */
|
|
61
|
+
|
|
62
|
+
/** local.yaml 读 ui_visual_gate(warn|error|off,默认 warn;CRLF 容错——手写行扫描器坑先例)。 */
|
|
63
|
+
export function readUiVisualGate(specBase) {
|
|
64
|
+
try {
|
|
65
|
+
const raw = readFileSync(join(specBase, 'local.yaml'), 'utf8')
|
|
66
|
+
const m = raw.match(/^\s*ui_visual_gate\s*:\s*['\"]?(warn|error|off)['\"]?\s*$/m)
|
|
67
|
+
return m ? m[1] : 'warn'
|
|
68
|
+
} catch {
|
|
69
|
+
return 'warn'
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const DOWNGRADE_LINE = /(降级|样式统一级|partial)/
|
|
74
|
+
const VISUAL_LINE = /(视觉|UI|页面|样式|界面)/
|
|
75
|
+
|
|
76
|
+
/** design/requirements 是否声明「视觉降级」(同行共现口径——行级命中防跨行误配;runUiVisualProbe 内部消费)。 */
|
|
77
|
+
function detectVisualDowngrade(designText, requirementsText) {
|
|
78
|
+
for (const t of [designText, requirementsText]) {
|
|
79
|
+
if (!t) continue
|
|
80
|
+
for (const line of t.split(/\r?\n/)) {
|
|
81
|
+
if (DOWNGRADE_LINE.test(line) && VISUAL_LINE.test(line)) return true
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return false
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* UI 视觉证据探针(只读变更目录;fail-soft 由调用方 catch 兜底)。
|
|
89
|
+
* level 三态:error(降级无裁决留痕,或 gate=error 且缺证据)→ 收口阻断;
|
|
90
|
+
* warn(默认档缺证据)→ advisory 警告;ok(证据在场,或降级但有裁决留痕)。
|
|
91
|
+
*/
|
|
92
|
+
export function runUiVisualProbe({ changeDir, gate = 'warn' }) {
|
|
93
|
+
const read = (name) => {
|
|
94
|
+
try { return readFileSync(join(changeDir, name), 'utf8') } catch { return '' }
|
|
95
|
+
}
|
|
96
|
+
const designText = read('design.md')
|
|
97
|
+
const requirementsText = read('requirements.md')
|
|
98
|
+
const proposalText = read('proposal.md')
|
|
99
|
+
const evidenceText = read(UI_EVIDENCE_FILENAME)
|
|
100
|
+
const decisionsText = read('decisions.md')
|
|
101
|
+
|
|
102
|
+
const declaredPaths = extractQuotedPaths(designText)
|
|
103
|
+
const uiTouched =
|
|
104
|
+
detectUiTouch(`${proposalText}\n${requirementsText}`) ||
|
|
105
|
+
detectUiTouchInPaths(declaredPaths)
|
|
106
|
+
|
|
107
|
+
if (gate === 'off') {
|
|
108
|
+
return { applicable: false, uiTouched, level: 'ok', notes: ['ui_visual_gate=off——探针关闭(含降级硬规则)'] }
|
|
109
|
+
}
|
|
110
|
+
if (!uiTouched) {
|
|
111
|
+
return { applicable: false, uiTouched: false, level: 'ok', notes: [] }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const evidencePresent = evidenceText.trim().length > 0
|
|
115
|
+
const downgradeDeclared = detectVisualDowngrade(designText, requirementsText)
|
|
116
|
+
const rulingPresent = evidenceText.includes('用户裁决') || decisionsText.includes('用户裁决')
|
|
117
|
+
|
|
118
|
+
let level = 'ok'
|
|
119
|
+
const notes = []
|
|
120
|
+
if (downgradeDeclared && !rulingPresent) {
|
|
121
|
+
level = 'error'
|
|
122
|
+
notes.push('design/requirements 存在视觉降级声明而变更目录无用户裁决留痕(visual-evidence.md 或 decisions.md 含「用户裁决」)——静默降级禁止收口')
|
|
123
|
+
} else if (!evidencePresent) {
|
|
124
|
+
level = gate === 'error' ? 'error' : 'warn'
|
|
125
|
+
notes.push(`变更目录缺 ${UI_EVIDENCE_FILENAME}(渲染对照证据应在执行时随手落盘——收口只验在场不产新证据)`)
|
|
126
|
+
} else if (downgradeDeclared && rulingPresent) {
|
|
127
|
+
notes.push('视觉降级已带用户裁决留痕')
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
applicable: true,
|
|
132
|
+
uiTouched: true,
|
|
133
|
+
evidencePresent,
|
|
134
|
+
downgradeDeclared,
|
|
135
|
+
rulingPresent,
|
|
136
|
+
level,
|
|
137
|
+
notes,
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** design 文件变更清单行的路径粗提(反引号/裸路径行——探针侧仅作 UI 扩展名兜底命中,不追求全解)。 */
|
|
142
|
+
function extractQuotedPaths(designText) {
|
|
143
|
+
if (!designText) return []
|
|
144
|
+
const paths = []
|
|
145
|
+
for (const m of designText.matchAll(/`([^`]+)`/g)) {
|
|
146
|
+
if (/[\\/]/.test(m[1])) paths.push(m[1])
|
|
147
|
+
}
|
|
148
|
+
for (const line of designText.split(/\r?\n/)) {
|
|
149
|
+
const t = line.trim()
|
|
150
|
+
const m = t.match(/^(?:修改|新增|删除|NEW:)?\s*((?:frontend|src|web|app|client|ui)\b[\\/][^\s|]+)/i)
|
|
151
|
+
if (m) paths.push(m[1])
|
|
152
|
+
}
|
|
153
|
+
return paths
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 探针 12 段渲染(renderProbe11Lines 同构:不适用 / ❌ error / ⚠️ warn / ✅ ok + 口径注记)。 */
|
|
157
|
+
export function renderUiVisualProbeLines(p12) {
|
|
158
|
+
const L = [UI_VISUAL_PROBE_HEADING]
|
|
159
|
+
if (!p12 || !p12.applicable) {
|
|
160
|
+
L.push(`- 不适用(${p12 && p12.uiTouched ? 'ui_visual_gate=off' : '非 UI 触达变更(input/声明文件面均未命中)——零打扰'})`)
|
|
161
|
+
for (const n of (p12 && p12.notes) || []) L.push(`- ℹ️ ${n}`)
|
|
162
|
+
return L
|
|
163
|
+
}
|
|
164
|
+
L.push('<!-- 口径注记:在场性检查非语义审计——只验 visual-evidence.md 存在非空与降级裁决留痕,不判对照结论对错(语义面归 verify-result 人工判断层)。证据应在执行时按 flow start「UI 变更执行须知」随手产生;本探针不要求收口现做。分级:缺证据默认 ⚠️(local.yaml ui_visual_gate=error 升阻断);视觉降级无「用户裁决」留痕恒 ❌(off 豁免)。 -->')
|
|
165
|
+
if (p12.level === 'error') {
|
|
166
|
+
for (const n of p12.notes) L.push(`- ❌ ${n}`)
|
|
167
|
+
L.push(' - 修复:补渲染对照证据到 visual-evidence.md;降级项补用户裁决留痕(含「用户裁决」字样段)后重跑收口')
|
|
168
|
+
} else if (p12.level === 'warn') {
|
|
169
|
+
for (const n of p12.notes) L.push(`- ⚠️ ${n}`)
|
|
170
|
+
} else {
|
|
171
|
+
L.push(`- ✅ UI 视觉证据在场(${UI_EVIDENCE_FILENAME} 非空${p12.downgradeDeclared ? ',降级带用户裁决留痕' : ''})`)
|
|
172
|
+
for (const n of p12.notes || []) L.push(`- ℹ️ ${n}`)
|
|
173
|
+
}
|
|
174
|
+
return L
|
|
175
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* verify-draft.js — verify 填槽制(r5l-forensic-verdict 方案 3 / 评审护栏#2,v2 件二)。
|
|
3
|
+
*
|
|
4
|
+
* 背景(R5-L 桶②):verify-result.md 被读写 14 次、每次 python-heredoc 整份回填一次全量重发
|
|
5
|
+
* (两次各挂起 7 分钟);模板 29KB 里 CLI 已能机械预填大部分。--draft 模式把四节 CLI 可算部分
|
|
6
|
+
* 预填成完整句子(任务完成度 summarizeTaskCompletion / 风险等级 resolveChangeRisk(detectChangeRisk
|
|
7
|
+
* 散文词表已退役 D-008)/ 测试结果 P2 test-ledger→质量扫描记录(与 verify 门对账同源同优先级)/
|
|
8
|
+
* 决策追踪 buildDecisionChainMatrix),agent 只填三处显式 <!--AGENT:--> 槽——工作流收敛为
|
|
9
|
+
* draft→填槽→复核,verify-result 读写 ≤3 次。
|
|
10
|
+
*
|
|
11
|
+
* 承重件 = --done 篡改门禁(护栏#2:守卫必须是验收侧的机制,永远不是生成侧的劝说):
|
|
12
|
+
* - 机器预填段以 MACHINE-DRAFT begin/end 注释对包裹,begin 标记内嵌该段内容的 sha256;
|
|
13
|
+
* - sidecar(.runtime/verify-draft-<change>.json)记录各段哈希与生成时点;
|
|
14
|
+
* - verify --done 对账(checkDraftIntegrity):标记被删 / 内容哈希失配 → 阻断回滚——
|
|
15
|
+
* heredoc 整份重写会同时破坏两者,无论 Write 可用与否均免疫(机制修法,不依赖劝说);
|
|
16
|
+
* - 确要改机器段:`verify-probes --change <名> --amend-draft` 重锚哈希并留痕
|
|
17
|
+
* (sidecar amendments 审计:时点 + 改动段清单)。
|
|
18
|
+
*
|
|
19
|
+
* 信任边界:sidecar 与骨架同在 agent 可写空间,伪造需主动协同造假——与 verify-facts.json /
|
|
20
|
+
* 质量扫描记录同级威胁模型(本门拦的是 heredoc 习惯性整份重写与意外覆盖,不是对抗性攻击)。
|
|
21
|
+
* 纯 --init(无 --draft)路径零变化;无 sidecar → 门禁 not-applicable(存量变更零红)。
|
|
22
|
+
*/
|
|
23
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
24
|
+
import { join } from 'node:path'
|
|
25
|
+
import { writeAtomicSync } from './fs-atomic.js'
|
|
26
|
+
// 三件套原语消费方化(R7 切片三 task-04):标记格式/指纹/三态判定/重锚的机械半边在本模块,
|
|
27
|
+
// verify-draft 保留 verify-result 专有面(骨架正则/槽契约/sidecar 路径)与全部既有导出签名。
|
|
28
|
+
import { bodyHash, wrapSection, verifyMarkers, reanchorText } from './machine-draft.js'
|
|
29
|
+
|
|
30
|
+
const sha256 = bodyHash
|
|
31
|
+
|
|
32
|
+
const AMEND_CMD = 'sillyspec verify-probes --change <变更名> --amend-draft'
|
|
33
|
+
|
|
34
|
+
/** sidecar 路径(.runtime 下,与质量扫描记录/探针回执同族) */
|
|
35
|
+
export function verifyDraftSidecarPath(runtimeRoot, changeName) {
|
|
36
|
+
return join(runtimeRoot, `verify-draft-${changeName}.json`)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 构建机器段:四节 CLI 可算部分的完整句子(纯函数——材料由调用方备齐,可测)。
|
|
41
|
+
* 各段 key 稳定(指纹与 sidecar 对账键);材料缺不到时给「降级说明句」而非空段——
|
|
42
|
+
* 机器段的降级也要是完整句子(读报告的人不需要反查工具行为)。
|
|
43
|
+
*/
|
|
44
|
+
export function buildDraftSections({ summarize, risk, testResult, decisionChain }) {
|
|
45
|
+
const sections = []
|
|
46
|
+
// ── 任务完成度(summarizeTaskCompletion 同源)──
|
|
47
|
+
sections.push({
|
|
48
|
+
key: 'task-completion',
|
|
49
|
+
body: [
|
|
50
|
+
summarize.report,
|
|
51
|
+
`- 总任务:${summarize.total};已完成(review verdict 口径):${summarize.completed}`,
|
|
52
|
+
summarize.pending && summarize.pending.length > 0
|
|
53
|
+
? `- 未完成:${summarize.pending.map((p) => `${p.id}(${p.reason || '未勾'})`).join('、')}`
|
|
54
|
+
: '- 未完成:无',
|
|
55
|
+
].filter(Boolean).join('\n'),
|
|
56
|
+
})
|
|
57
|
+
// ── 变更风险等级(resolveChangeRisk 同源,evaluateConclusionDraft 先例口径)──
|
|
58
|
+
sections.push({
|
|
59
|
+
key: 'risk-level',
|
|
60
|
+
body: risk
|
|
61
|
+
? [
|
|
62
|
+
`- 机器判级:tier=${risk.tier}${risk.explicit ? `(design.md 显式声明 = ${risk.level}——显式声明已入判定)` : '(design.md 无显式 risk_level 声明)'}`,
|
|
63
|
+
risk.evidenceRequired
|
|
64
|
+
? `- ⚠️ 命中 evidence:true 声明危险面(${(risk.hitPrefixes || []).join('、')})——integration/deployment-critical 档:需集成证据链(回执槽 + Runtime Evidence),下方「集成验证回执」为例外人工槽`
|
|
65
|
+
: '- 未命中 evidence:true 声明危险面——无集成证据链硬要求(「集成验证回执」节机器判「无」)',
|
|
66
|
+
'- 判级输入:design 文件清单 × blast 声明(同 verify 门 evaluateConclusionDraft 口径;判定被新事实推翻时在「审查叙述」槽说明)',
|
|
67
|
+
].join('\n')
|
|
68
|
+
: '- 机器判级降级:无法解析 design 文件清单/blast 声明(无 design.md 或清单为空)——按审查叙述确认实际等级(局部修补通常 doc-only / unit-sufficient)',
|
|
69
|
+
})
|
|
70
|
+
// ── 测试结果(P2 账本 → 质量扫描记录,与 verify 门对账同源同优先级)──
|
|
71
|
+
sections.push({
|
|
72
|
+
key: 'test-result',
|
|
73
|
+
body: testResult
|
|
74
|
+
? [
|
|
75
|
+
`- ${testResult.source === 'ledger' ? '♻️ P2 三键账本复用(代码×测试面×环境全等)' : '♻️ noAI 质量扫描实测记录复用(代码指纹匹配)'}:\`${testResult.command}\` — ${testResult.status === 'passed' ? '通过' : testResult.status}`,
|
|
76
|
+
`- 实测于 ${testResult.ranAt}${testResult.durationMs != null ? `,耗时 ${Math.round(testResult.durationMs / 1000)}s` : ''}${testResult.total != null ? `,${testResult.total} 断言` : ''}`,
|
|
77
|
+
'- verify `--done` 门与本文对账同源(P2 账本 > 扫描记录 > 亲跑)——正文与门结论冲突时以门为准并在此说明差异',
|
|
78
|
+
].join('\n')
|
|
79
|
+
: '- 本 draft 生成时点无 P2 账本/质量扫描记录(verify 步「运行测试和质量扫描」尚未跑)——该步实测后门对账自动落账,此处以门结论为准(无需手改本段)',
|
|
80
|
+
})
|
|
81
|
+
// ── 决策追踪(buildDecisionChainMatrix 机械半边)──
|
|
82
|
+
sections.push({
|
|
83
|
+
key: 'decision-chain',
|
|
84
|
+
body: decisionChain && decisionChain.rows && decisionChain.rows.length > 0
|
|
85
|
+
? [
|
|
86
|
+
`- 决策链机械半边:${decisionChain.decisionCount} 条决策 × ${decisionChain.taskCount} 张 task 卡(D→FR→Task 自 decisions.md × tasks/*.md frontmatter 构建)`,
|
|
87
|
+
'| 决策 ID | FR | Task | Evidence | 状态 |',
|
|
88
|
+
'|---|---|---|---|---|',
|
|
89
|
+
...decisionChain.rows,
|
|
90
|
+
'- Evidence / 状态两列是人工判断(机器不代笔)——逐格复核,未闭环行在「审查叙述」槽标注风险',
|
|
91
|
+
].join('\n')
|
|
92
|
+
: '- 本变更无 decisions.md(或零决策条目)——决策追踪无机器面对账,如执行期做过真实取舍在「审查叙述」槽说明',
|
|
93
|
+
})
|
|
94
|
+
return sections
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 段 key → 骨架标题关键词(骨架标题行包含这些词即命中该段) */
|
|
98
|
+
function sectionKeyword(key) {
|
|
99
|
+
switch (key) {
|
|
100
|
+
case 'task-completion': return '任务完成度'
|
|
101
|
+
case 'risk-level': return '变更风险等级'
|
|
102
|
+
case 'test-result': return '测试结果'
|
|
103
|
+
case 'decision-chain': return '决策追踪矩阵'
|
|
104
|
+
default: return key
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* draft 化主入口:骨架文本 → draft 文本(机器段替换 + 三 AGENT 槽标注 + 顶部模式横幅)。
|
|
110
|
+
* 纯文本变换(落盘与 sidecar 归 applyDraftMode)。
|
|
111
|
+
* @returns {{ text: string, applied: string[], slots: number }}
|
|
112
|
+
*/
|
|
113
|
+
export function transformSkeletonToDraft(skeletonText, sections) {
|
|
114
|
+
let text = skeletonText
|
|
115
|
+
const applied = []
|
|
116
|
+
for (const sec of sections) {
|
|
117
|
+
const kw = sectionKeyword(sec.key)
|
|
118
|
+
const headRe = new RegExp(`^(## [^\\n]*${kw}[^\\n]*\\n)([\\s\\S]*?)(?=^## |\\n*$)`, 'm')
|
|
119
|
+
const m = text.match(headRe)
|
|
120
|
+
if (!m) continue
|
|
121
|
+
const body = m[2]
|
|
122
|
+
// 幂等闸:段内已有 MACHINE-DRAFT 标记或已非 TODO 占位(手写正文)→ 不动
|
|
123
|
+
if (body.includes('MACHINE-DRAFT:')) continue
|
|
124
|
+
if (!/<!--TODO|<待填|<!-- 结论=|<!--\s*无 cannot_verify/.test(body)) continue
|
|
125
|
+
const wrapped = wrapSection({ key: sec.key, body: sec.body, amendCmd: AMEND_CMD })
|
|
126
|
+
text = text.slice(0, m.index) + m[1] + wrapped + text.slice(m.index + m[0].length)
|
|
127
|
+
applied.push(sec.key)
|
|
128
|
+
}
|
|
129
|
+
// 三 AGENT 槽标注(幂等:已有标注零改动)——结论枚举槽行契约不动(gate 消费面)
|
|
130
|
+
if (!text.includes('<!--AGENT:')) {
|
|
131
|
+
text = text
|
|
132
|
+
.replace(/(结论枚举:`<待填:三选一>`)/, '<!--AGENT:槽1/3 结论枚举——替换上行占位,槽行格式勿改(gate 判定消费面) -->\n$1')
|
|
133
|
+
.replace(/(\| <待填:env-blocked \/ manual-acceptance \/ db-script \/ other> \| <待填:移交条目> \| <待填:复跑\/验收条件> \|)/, '<!--AGENT:槽2/3 移交项——按需增行;结论=PASS/FAIL 时本表写「无」 -->\n$1')
|
|
134
|
+
.replace(/(## 设计一致性 \[层:人工判断\]\n)(<!--TODO[^>]*-->)/, '$1<!--AGENT:槽3/3 审查叙述——设计偏差(无偏差显式写「一致」)+ 技术债务叙述(探针 1 统计已机器预填在「技术债务」节);替换下方 TODO 注释为正文 -->\n$2')
|
|
135
|
+
}
|
|
136
|
+
// 顶部横幅(draft 模式可见性:读写 ≤3 次的工作流说明——机制不带劝说,只说事实)
|
|
137
|
+
if (!text.includes('<!-- VERIFY-DRAFT-MODE -->') && applied.length > 0) {
|
|
138
|
+
const banner = [
|
|
139
|
+
'<!-- VERIFY-DRAFT-MODE -->',
|
|
140
|
+
'> **填槽模式**:机器段(MACHINE-DRAFT 标记包裹,篡改会被 --done 拒收)之外,你只填三处',
|
|
141
|
+
'> AGENT 槽:①结论枚举 ②移交项 ③审查叙述。工作流 = 本 draft → 填槽 → `--done` 复核,',
|
|
142
|
+
'> verify-result 读写 ≤3 次。改机器段唯一通道:`' + AMEND_CMD + '`(留痕重锚)。',
|
|
143
|
+
'',
|
|
144
|
+
].join('\n')
|
|
145
|
+
text = text.replace(/(^# [^\n]*\n)/, `$1\n${banner}`)
|
|
146
|
+
}
|
|
147
|
+
return { text, applied, slots: 3 }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* draft 模式落盘 + sidecar 记账(--init --draft 消费)。
|
|
152
|
+
* @returns {{ applied: string[], sidecarPath: string }|null} 无可替换段(非骨架态/已 draft)→ null
|
|
153
|
+
*/
|
|
154
|
+
export function applyDraftMode({ mdPath, changeName, runtimeRoot, sections }) {
|
|
155
|
+
let text
|
|
156
|
+
try { text = readFileSync(mdPath, 'utf8') } catch { return null }
|
|
157
|
+
const { text: next, applied } = transformSkeletonToDraft(text, sections)
|
|
158
|
+
if (applied.length === 0) return null
|
|
159
|
+
try {
|
|
160
|
+
writeAtomicSync(mdPath, next)
|
|
161
|
+
} catch (e) {
|
|
162
|
+
console.warn(`⚠️ verify draft 落盘失败(fail-soft,骨架保持原态): ${e && e.message ? e.message : e}`)
|
|
163
|
+
return null
|
|
164
|
+
}
|
|
165
|
+
const sidecar = { schemaVersion: 1, change: changeName, generatedAt: new Date().toISOString(), sections: {}, amendments: [] }
|
|
166
|
+
for (const sec of sections) {
|
|
167
|
+
if (applied.includes(sec.key)) sidecar.sections[sec.key] = { hash: sha256(sec.body) }
|
|
168
|
+
}
|
|
169
|
+
const sidecarPath = verifyDraftSidecarPath(runtimeRoot, changeName)
|
|
170
|
+
try { writeAtomicSync(sidecarPath, JSON.stringify(sidecar, null, 2) + '\n') } catch { /* sidecar 失败 → 门禁 not-applicable(降级放行,不阻断) */ }
|
|
171
|
+
return { applied, sidecarPath }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* --done 篡改门禁(承重件,验收侧):sidecar 在案时逐段对账。
|
|
176
|
+
* @returns {{ applicable: boolean, ok: boolean, violations: string[] }}
|
|
177
|
+
* not-applicable(无 sidecar)→ 放行(存量/非 draft 变更零红);
|
|
178
|
+
* 违规 = 标记被删(含整段删除)/ 内容哈希失配(整份重写必中)。
|
|
179
|
+
*/
|
|
180
|
+
export function checkDraftIntegrity({ mdPath, changeName, runtimeRoot }) {
|
|
181
|
+
const sidecarPath = verifyDraftSidecarPath(runtimeRoot, changeName)
|
|
182
|
+
let sidecar = null
|
|
183
|
+
try { sidecar = JSON.parse(readFileSync(sidecarPath, 'utf8')) } catch { return { applicable: false, ok: true, violations: [] } }
|
|
184
|
+
if (!sidecar || sidecar.schemaVersion !== 1 || !sidecar.sections) return { applicable: false, ok: true, violations: [] }
|
|
185
|
+
let text = ''
|
|
186
|
+
try { text = readFileSync(mdPath, 'utf8') } catch {
|
|
187
|
+
return { applicable: true, ok: false, violations: ['verify-result.md 不可读(draft sidecar 在案)'] }
|
|
188
|
+
}
|
|
189
|
+
const violations = verifyMarkers({ text, sections: sidecar.sections, amendCmd: AMEND_CMD })
|
|
190
|
+
return { applicable: true, ok: violations.length === 0, violations }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* --amend-draft:对 md 中现存标记段按**当前内容**重锚哈希(标记与 sidecar 同步刷新),
|
|
195
|
+
* sidecar 追加 amendment 审计(时点 + 段清单)。返回重锚段键(空 = 无可重锚标记)。
|
|
196
|
+
*/
|
|
197
|
+
export function amendDraftMarkers({ mdPath, changeName, runtimeRoot }) {
|
|
198
|
+
let text
|
|
199
|
+
try { text = readFileSync(mdPath, 'utf8') } catch { return [] }
|
|
200
|
+
const { text: next, keys, contentByKey } = reanchorText({ text, amendCmd: AMEND_CMD })
|
|
201
|
+
if (keys.length === 0) return []
|
|
202
|
+
try { writeAtomicSync(mdPath, next) } catch (e) {
|
|
203
|
+
console.warn(`⚠️ amend-draft 重锚落盘失败: ${e && e.message ? e.message : e}`)
|
|
204
|
+
return []
|
|
205
|
+
}
|
|
206
|
+
// sidecar:合并既有 + amendment 审计
|
|
207
|
+
const sidecarPath = verifyDraftSidecarPath(runtimeRoot, changeName)
|
|
208
|
+
let sidecar = { schemaVersion: 1, change: changeName, generatedAt: new Date().toISOString(), sections: {}, amendments: [] }
|
|
209
|
+
try { sidecar = { ...sidecar, ...JSON.parse(readFileSync(sidecarPath, 'utf8')) } } catch { /* 无既有 → 新立 */ }
|
|
210
|
+
for (const k of keys) sidecar.sections[k] = { hash: sha256(contentByKey[k]) }
|
|
211
|
+
sidecar.amendments = [...(sidecar.amendments || []), { at: new Date().toISOString(), keys }]
|
|
212
|
+
try { writeAtomicSync(sidecarPath, JSON.stringify(sidecar, null, 2) + '\n') } catch { /* 审计失败不回滚重锚 */ }
|
|
213
|
+
return keys
|
|
214
|
+
}
|