dsh-rule-engine 0.6.2 → 0.6.4

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.
@@ -0,0 +1,481 @@
1
+ // dualtrack-check.mjs - 分层残留扫描闸(dualtrack-check)
2
+ //
3
+ // 依据:本机使用手册 SKILL.md:113「修改双轨制(2026-08-31 用户定稿)——
4
+ // 铁律=代码层零本机内容(发布门禁扫描红);机器校验(dualtrack-check)为验收兜底」。
5
+ // 本脚本是那句承诺的落地实现。
6
+ //
7
+ // 扫描范围(lib/**/*.js)——判据 A(2026-09-09 用户拍板,与方案 §一 一致):
8
+ // 闸只扫「**会随发布者/规则集/环境变化**」的内容,不扫通用中文文案。
9
+ // ① 本机标识:发布者私有词表(rule-engine.json 的 dualtrack.markers)命中的字符串
10
+ // ② 映射表键:对象字面量键名匹配 ^\d+[A-Z]?$ 或含 CJK(规则号索引=某人的规则体系)
11
+ // —— 通用中文文案(字符串字面量内的 CJK)**不计入**:第三方判据明示「中文≠个人化,
12
+ // 通用功能词/提示语是产品能力」。runtime 计数仍在 --report 里显示,供参考。
13
+ //
14
+ // 扫描判据 D(2026-09-10 分层架构 v3 · P0 追加):规则号字面量
15
+ // 依据:reports/2026-09-10-引擎分层架构-通用层零规则内容-v1.md §七。
16
+ // 模式:rule\d+[A-Za-z]?- / byId\.get\("\d+"\) / \[guardian:rule\d+\] / rule-hint\.\d+
17
+ // / ruleId\s*[=!]==?\s*"<N>"(2026-09-10 用户拍板纳入;§七 原列四条,此条据 §二 现状证据补入)
18
+ // 为何单列一判据:「机制以规则号为骨架」比「文案含编号」更根本,且与判据 A 不同维度
19
+ // (A 抓本机私有词/映射键;D 抓通用层住着规则体系),故各自独立棘轮。
20
+ // 口径:去注释后扫描(注释属开发溯源,非机制骨架);不设白名单——代码层零规则号。
21
+ //
22
+ // 棘轮(ratchet):基线记录各文件计数,只许降不许升。
23
+ // node scripts/dualtrack-check.mjs # 比对基线(CI/发布门禁用)
24
+ // node scripts/dualtrack-check.mjs --init # 首次生成基线
25
+ // node scripts/dualtrack-check.mjs --update # 手动更新基线(须在提交说明里写清改了什么)
26
+ // node scripts/dualtrack-check.mjs --report # 只打印各文件计数
27
+ // node scripts/dualtrack-check.mjs --root <dir> # 改扫描根(仅测试隔离用;非默认根会显著提示)
28
+ //
29
+ // 白名单(scripts/dualtrack-whitelist.json):
30
+ // files —— 整文件豁免(如 lib/lang/**,第 3 批语言包)
31
+ // strings —— 通用功能词精确豁免(第三方 §一:中文≠个人化,许可词/时间词是产品能力)
32
+ import fs from "node:fs";
33
+ import os from "node:os";
34
+ import path from "node:path";
35
+ import { fileURLToPath } from "node:url";
36
+ import { loadMarkers } from "../lib/core/dualtrack-markers.js";
37
+
38
+ // ── 命令行参数(提前解析:--root 必须在下面的路径常量之前生效)──
39
+ const ARGV = process.argv.slice(2);
40
+ const args = new Set(ARGV);
41
+ const rootIdx = ARGV.indexOf("--root");
42
+ const IS_CUSTOM_ROOT = rootIdx >= 0;
43
+ const ROOT = IS_CUSTOM_ROOT && ARGV[rootIdx + 1]
44
+ ? path.resolve(ARGV[rootIdx + 1])
45
+ : path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
46
+ const LIB = path.join(ROOT, "lib");
47
+ const BASELINE_FILE = path.join(ROOT, "scripts", "dualtrack-baseline.json");
48
+ const WHITELIST_FILE = path.join(ROOT, "scripts", "dualtrack-whitelist.json");
49
+ const RESIDUE_FILE = path.join(ROOT, "scripts", "local-residue-markers.txt");
50
+
51
+ const CJK = /[\u4e00-\u9fff]/;
52
+ const MAP_KEY_RE = /^\d+[A-Z]?$/;
53
+
54
+ // ── 判据 D:规则号字面量(§七;口径=去注释后扫描,无白名单)──
55
+ const RULE_LITERAL_PATTERNS = [
56
+ ["executor-name", /rule\d+[A-Za-z]?-/g, "内部执行器名 rule<N>[x]-"],
57
+ ["byid-get", /byId\.get\(\s*["'`]\d+["'`]\s*\)/g, '按规则号取规则 byId.get("<N>")'],
58
+ ["guardian-tag", /\[guardian:rule\d+\]/g, "对外文案 [guardian:rule<N>]"],
59
+ ["rule-hint", /rule-hint\.\d+/g, "rule-hint.<N>"],
60
+ ["ruleid-eq", /ruleId\s*[=!]==?\s*["'`]\d+[A-Za-z]?["'`]/g, '硬编码规则号比较 ruleId === "<N>"']
61
+ ];
62
+
63
+ // ── 白名单 ──
64
+ /** 白名单两层(2026-09-09):包内通用白名单 + 本机 rule-engine.json 的 dualtrack.whitelist 合并。
65
+ * 包内那份随包发布(如 lib/lang/**);本机那份记录「我豁免我自己的某条」,不进包。 */
66
+ function loadWhitelist() {
67
+ const files = [];
68
+ const strings = new Set();
69
+ // ① 包内通用白名单
70
+ if (fs.existsSync(WHITELIST_FILE)) {
71
+ try {
72
+ const w = JSON.parse(fs.readFileSync(WHITELIST_FILE, "utf8"));
73
+ for (const f of w.files || []) files.push(f);
74
+ for (const s of w.strings || []) strings.add(s);
75
+ } catch (e) {
76
+ throw new Error(`白名单解析失败:${WHITELIST_FILE} — ${e.message}`);
77
+ }
78
+ }
79
+ // ② 本机白名单(rule-engine.json 的 dualtrack.whitelist)
80
+ try {
81
+ const p = path.join(process.env.DSH_HOME || path.join(os.homedir(), ".dsh"), "rule-engine.json");
82
+ if (fs.existsSync(p)) {
83
+ const cfg = JSON.parse(fs.readFileSync(p, "utf8"));
84
+ const w = cfg?.dualtrack?.whitelist;
85
+ if (w && typeof w === "object") {
86
+ for (const f of w.files || []) files.push(f);
87
+ for (const s of w.strings || []) strings.add(s);
88
+ }
89
+ }
90
+ } catch {
91
+ // 本机配置不可读 → 只用包内白名单(不阻断)
92
+ }
93
+ return { files, strings };
94
+ }
95
+
96
+ /** 整文件豁免匹配(支持 ** 与 * 通配,路径用 / 分隔,相对仓库根) */
97
+ function fileExempt(relPath, patterns) {
98
+ for (const p of patterns) {
99
+ const re = new RegExp("^" + p.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\u0000").replace(/\*/g, "[^/]*").replace(/\u0000/g, ".*") + "$");
100
+ if (re.test(relPath)) return true;
101
+ }
102
+ return false;
103
+ }
104
+
105
+ // ── 去注释(判据 D 用):保留字符串/模板/正则原文,注释替换为空白(保持行结构) ──
106
+ function stripComments(src) {
107
+ let out = "";
108
+ let i = 0;
109
+ const n = src.length;
110
+ let prevSig = "";
111
+ const push = (s) => { out += s; };
112
+ while (i < n) {
113
+ const c = src[i];
114
+ const c2 = src[i + 1];
115
+ if (c === "/" && c2 === "/") {
116
+ while (i < n && src[i] !== "\n") { push(" "); i++; }
117
+ continue;
118
+ }
119
+ if (c === "/" && c2 === "*") {
120
+ push(" "); i += 2;
121
+ while (i < n && !(src[i] === "*" && src[i + 1] === "/")) {
122
+ push(src[i] === "\n" ? "\n" : " ");
123
+ i++;
124
+ }
125
+ if (i < n) { push(" "); i += 2; }
126
+ continue;
127
+ }
128
+ if (c === '"' || c === "'" || c === "`") {
129
+ const q = c;
130
+ push(c); i++;
131
+ while (i < n) {
132
+ if (src[i] === "\\") { push(src[i] + (src[i + 1] || "")); i += 2; continue; }
133
+ push(src[i]);
134
+ if (src[i] === q) { i++; break; }
135
+ i++;
136
+ }
137
+ prevSig = "str";
138
+ continue;
139
+ }
140
+ // 正则字面量:仅在可能的正则位置整体复制(否则 / 是除号)
141
+ if (c === "/" && prevSig !== "ident" && prevSig !== ")" && prevSig !== "]" && prevSig !== "str") {
142
+ let j = i + 1;
143
+ let inClass = false;
144
+ let closed = false;
145
+ while (j < n) {
146
+ const ch = src[j];
147
+ if (ch === "\\") { j += 2; continue; }
148
+ if (ch === "\n") break;
149
+ if (ch === "[") inClass = true;
150
+ else if (ch === "]") inClass = false;
151
+ else if (ch === "/" && !inClass) { closed = true; break; }
152
+ j++;
153
+ }
154
+ if (closed) {
155
+ push(src.slice(i, j + 1));
156
+ i = j + 1;
157
+ while (i < n && /[a-z]/i.test(src[i])) { push(src[i]); i++; }
158
+ prevSig = "regex";
159
+ continue;
160
+ }
161
+ }
162
+ push(c);
163
+ if (/[A-Za-z0-9_$]/.test(c)) prevSig = "ident";
164
+ else if (c === ")") prevSig = ")";
165
+ else if (c === "]") prevSig = "]";
166
+ else if (!/\s/.test(c)) prevSig = "other";
167
+ i++;
168
+ }
169
+ return out;
170
+ }
171
+
172
+ // ── 词法扫描:剔除注释,收集字符串字面量与对象键 ──
173
+ /**
174
+ * @returns {{strings: string[], mapKeys: string[]}}
175
+ * strings = 字符串字面量的**内容**(模板串按整段处理)
176
+ * mapKeys = 形如 "14": / '12E': / 14: 的对象键
177
+ */
178
+ function tokenize(src) {
179
+ const strings = [];
180
+ const mapKeys = [];
181
+ let i = 0;
182
+ const n = src.length;
183
+ // 上一个有意义的 token 类型:用于判断 `/` 是正则还是除号(简化:只看是否可能在正则位置)
184
+ let prevSig = "";
185
+ while (i < n) {
186
+ const c = src[i];
187
+ // 行注释
188
+ if (c === "/" && src[i + 1] === "/") {
189
+ while (i < n && src[i] !== "\n") i++;
190
+ continue;
191
+ }
192
+ // 块注释(含 JSDoc)
193
+ if (c === "/" && src[i + 1] === "*") {
194
+ i += 2;
195
+ while (i < n && !(src[i] === "*" && src[i + 1] === "/")) i++;
196
+ i += 2;
197
+ continue;
198
+ }
199
+ // 字符串 / 模板串
200
+ if (c === '"' || c === "'" || c === "`") {
201
+ const q = c;
202
+ const start = i;
203
+ i++;
204
+ let buf = "";
205
+ if (q === "`") {
206
+ // 模板串:整段作为一个字符串,但 `${...}` 内的表达式跳过(不计入内容,也不当键)
207
+ while (i < n) {
208
+ const ch = src[i];
209
+ if (ch === "\\") { buf += ch + (src[i + 1] || ""); i += 2; continue; }
210
+ if (ch === "$" && src[i + 1] === "{") {
211
+ let depth = 1;
212
+ i += 2;
213
+ while (i < n && depth > 0) {
214
+ const d = src[i];
215
+ if (d === "\\") { i += 2; continue; }
216
+ if (d === "{") depth++;
217
+ else if (d === "}") depth--;
218
+ else if (d === '"' || d === "'" || d === "`") {
219
+ const qq = d; i++;
220
+ while (i < n && src[i] !== qq) { if (src[i] === "\\") i++; i++; }
221
+ }
222
+ i++;
223
+ }
224
+ buf += "\u0000"; // 表达式占位(不含 CJK)
225
+ continue;
226
+ }
227
+ if (ch === "`") break;
228
+ buf += ch;
229
+ i++;
230
+ }
231
+ } else {
232
+ while (i < n && src[i] !== q) {
233
+ if (src[i] === "\\") { buf += src[i] + (src[i + 1] || ""); i += 2; continue; }
234
+ buf += src[i];
235
+ i++;
236
+ }
237
+ }
238
+ i++; // 收尾引号
239
+ strings.push(buf);
240
+ // 对象键判定(2026-09-09 修正):必须「前面是 { 或 ,」且「后面(跳空白)是 :」
241
+ // —— 否则模板串分段与三元表达式会被误判成键(20 处误报的根因)。
242
+ let j = i;
243
+ while (j < n && /\s/.test(src[j])) j++;
244
+ if (src[j] === ":") {
245
+ let p = start - 1;
246
+ while (p >= 0 && /\s/.test(src[p])) p--;
247
+ if (src[p] === "{" || src[p] === ",") {
248
+ const plain = buf.replace(/\\(.)/g, "$1");
249
+ if (MAP_KEY_RE.test(plain) || CJK.test(plain)) mapKeys.push(plain);
250
+ }
251
+ }
252
+ prevSig = "str";
253
+ continue;
254
+ }
255
+ // 正则字面量(跳过,不计入)
256
+ if (c === "/" && prevSig !== "ident" && prevSig !== ")" && prevSig !== "]") {
257
+ let j = i + 1;
258
+ let inClass = false;
259
+ let closed = false;
260
+ while (j < n) {
261
+ const ch = src[j];
262
+ if (ch === "\\") { j += 2; continue; }
263
+ if (ch === "\n") break;
264
+ if (ch === "[") inClass = true;
265
+ else if (ch === "]") inClass = false;
266
+ else if (ch === "/" && !inClass) { closed = true; break; }
267
+ j++;
268
+ }
269
+ if (closed) {
270
+ i = j + 1;
271
+ while (i < n && /[a-z]/i.test(src[i])) i++;
272
+ prevSig = "regex";
273
+ continue;
274
+ }
275
+ i++;
276
+ prevSig = "op";
277
+ continue;
278
+ }
279
+ if (/[A-Za-z_$]/.test(c)) {
280
+ let j = i;
281
+ while (j < n && /[A-Za-z0-9_$]/.test(src[j])) j++;
282
+ i = j;
283
+ prevSig = "ident";
284
+ continue;
285
+ }
286
+ if (c === ")") { prevSig = ")"; i++; continue; }
287
+ if (c === "]") { prevSig = "]"; i++; continue; }
288
+ if (!/\s/.test(c)) prevSig = "op";
289
+ i++;
290
+ }
291
+ return { strings, mapKeys };
292
+ }
293
+
294
+ // ── 单文件扫描 ──
295
+ function scanFile(absPath, relPath, whitelist, residueMarks) {
296
+ const src = fs.readFileSync(absPath, "utf8");
297
+ const { strings, mapKeys } = tokenize(src);
298
+ // 判据 D:去注释后扫规则号字面量
299
+ const code = stripComments(src);
300
+ let ruleLiterals = 0;
301
+ const ruleLiteralHits = [];
302
+ for (const [id, re, desc] of RULE_LITERAL_PATTERNS) {
303
+ const m = code.match(re);
304
+ if (!m) continue;
305
+ ruleLiterals += m.length;
306
+ if (ruleLiteralHits.length < 3) ruleLiteralHits.push(`${id}×${m.length}(${desc})如 ${m[0]}`);
307
+ }
308
+ let runtime = 0;
309
+ const runtimeHits = [];
310
+ for (const s of strings) {
311
+ if (!CJK.test(s)) continue;
312
+ const plain = s.replace(/\\(.)/g, "$1").trim();
313
+ if (whitelist.strings.has(plain)) continue; // 通用功能词豁免
314
+ runtime++;
315
+ if (runtimeHits.length < 3) runtimeHits.push(plain.slice(0, 40));
316
+ }
317
+ const mapHits = mapKeys.filter((k) => MAP_KEY_RE.test(k) || CJK.test(k));
318
+ let local = 0;
319
+ const localHits = [];
320
+ for (const s of strings) {
321
+ for (const mark of residueMarks) {
322
+ if (mark && s.includes(mark)) {
323
+ local++;
324
+ if (localHits.length < 3) localHits.push(`${mark} ← ${s.slice(0, 40)}`);
325
+ }
326
+ }
327
+ }
328
+ return {
329
+ relPath,
330
+ runtime,
331
+ mapKeys: mapHits.length,
332
+ local,
333
+ ruleLiterals,
334
+ // 判据 A:total 只计「本机性」两类;通用中文文案(runtime)不计入闸
335
+ total: mapHits.length + local,
336
+ runtimeHits,
337
+ mapHits: mapHits.slice(0, 5),
338
+ localHits,
339
+ ruleLiteralHits
340
+ };
341
+ }
342
+
343
+ function walk(dir, base = dir, out = []) {
344
+ for (const e of fs.readdirSync(dir)) {
345
+ const p = path.join(dir, e);
346
+ const st = fs.statSync(p);
347
+ if (st.isDirectory()) walk(p, base, out);
348
+ else if (/\.js$/.test(e)) out.push(path.relative(base, p).replace(/\\/g, "/"));
349
+ }
350
+ return out;
351
+ }
352
+
353
+ // ── 主流程 ──
354
+ if (IS_CUSTOM_ROOT) console.log(`SCAN ROOT(非默认,仅测试隔离用):${ROOT}\n`);
355
+ const whitelist = loadWhitelist();
356
+ // 本机标识词表:与 B2 扫描器共用唯一加载器(本机配置优先 → 环境变量 → 包内示例)
357
+ const { markers: residueMarks, source: markersSource } = loadMarkers({ root: ROOT });
358
+ if (residueMarks.length === 0) {
359
+ console.error("REFUSED: 本机标识词表为空——请在 rule-engine.json 配置 dualtrack.markers,或设置 DUALTRACK_MARKERS 环境变量");
360
+ process.exit(1);
361
+ }
362
+
363
+ const files = walk(LIB).filter((rel) => !fileExempt(rel, whitelist.files));
364
+ const results = files.map((rel) => scanFile(path.join(LIB, rel), rel, whitelist, residueMarks));
365
+ const counts = {};
366
+ const ruleCounts = {};
367
+ for (const r of results) {
368
+ counts[`lib/${r.relPath}`] = r.total;
369
+ ruleCounts[`lib/${r.relPath}`] = r.ruleLiterals;
370
+ }
371
+ const grandTotal = results.reduce((a, r) => a + r.total, 0);
372
+ const ruleGrandTotal = results.reduce((a, r) => a + r.ruleLiterals, 0);
373
+
374
+ if (args.has("--report")) {
375
+ for (const r of results.sort((a, b) => (b.total + b.ruleLiterals) - (a.total + a.ruleLiterals))) {
376
+ if (r.total === 0 && r.ruleLiterals === 0) continue;
377
+ console.log(` ${String(r.total).padStart(4)} lib/${r.relPath} [映射键 ${r.mapKeys} / 本机标识 ${r.local} / 通用中文文案 ${r.runtime}(不计入闸)] [规则号字面量 ${r.ruleLiterals}]`);
378
+ if (r.mapHits.length) console.log(` 命中键:${r.mapHits.join(" / ")}`);
379
+ if (r.ruleLiteralHits.length) console.log(` 规则号字面量:${r.ruleLiteralHits.join(";")}`);
380
+ }
381
+ console.log(`\nDUALTRACK REPORT:${results.length} 文件,判据 A(分层残留)合计 ${grandTotal},判据 D(规则号字面量)合计 ${ruleGrandTotal}`);
382
+ process.exit(0);
383
+ }
384
+
385
+ if (args.has("--init") || args.has("--update")) {
386
+ const isUpdate = args.has("--update");
387
+ if (isUpdate && !fs.existsSync(BASELINE_FILE)) {
388
+ console.error("REFUSED: --update 需要已存在的基线;首次请用 --init");
389
+ process.exit(1);
390
+ }
391
+ if (!isUpdate && fs.existsSync(BASELINE_FILE)) {
392
+ console.error(
393
+ `REFUSED: 基线已存在(${BASELINE_FILE})——--init 仅用于首次生成。\n` +
394
+ " --init 会把当前计数覆盖为基线(棘轮失效);确需更新请用 --update(须在提交说明里写清改了什么)"
395
+ );
396
+ process.exit(1);
397
+ }
398
+ const payload = {
399
+ generatedAt: new Date().toISOString(),
400
+ note: "dualtrack 棘轮基线:各文件计数,只许降不许升。files/total=判据 A(分层残留);ruleLiteralFiles/ruleLiteralsTotal=判据 D(规则号字面量)。--init 首次生成,--update 手动更新(须在提交说明里写清改了什么)。",
401
+ total: grandTotal,
402
+ files: counts,
403
+ ruleLiteralsTotal: ruleGrandTotal,
404
+ ruleLiteralFiles: ruleCounts
405
+ };
406
+ fs.writeFileSync(BASELINE_FILE, JSON.stringify(payload, null, 2) + "\n", "utf8");
407
+ console.log(`${isUpdate ? "UPDATED" : "INITIALIZED"} ${BASELINE_FILE}`);
408
+ console.log(`基线:判据 A 合计 ${grandTotal}(${Object.keys(counts).length} 文件)/判据 D 合计 ${ruleGrandTotal}`);
409
+ process.exit(0);
410
+ }
411
+
412
+ // 默认:比对基线
413
+ if (!fs.existsSync(BASELINE_FILE)) {
414
+ console.error(`FAIL 基线不存在:${BASELINE_FILE}\n 首次请运行:node scripts/dualtrack-check.mjs --init`);
415
+ process.exit(1);
416
+ }
417
+ const baseline = JSON.parse(fs.readFileSync(BASELINE_FILE, "utf8"));
418
+ const base = baseline.files || {};
419
+ const regressions = [];
420
+ const improvements = [];
421
+ for (const [file, count] of Object.entries(counts)) {
422
+ const b = base[file];
423
+ if (b === undefined) {
424
+ if (count > 0) regressions.push(`${file}: 新增文件含残留 ${count} 处(基线无此文件)`);
425
+ continue;
426
+ }
427
+ if (count > b) {
428
+ const r = results.find((x) => `lib/${x.relPath}` === file);
429
+ const detail = r ? [...r.runtimeHits, ...r.localHits].slice(0, 2).join(";") : "";
430
+ regressions.push(`${file}: ${count} > 基线 ${b}${detail ? `(如:${detail})` : ""}`);
431
+ } else if (count < b) {
432
+ improvements.push(`${file}: ${count} < 基线 ${b}`);
433
+ }
434
+ }
435
+ for (const [file, b] of Object.entries(base)) {
436
+ if (counts[file] === undefined && b > 0) improvements.push(`${file}: 文件已删除(基线 ${b})`);
437
+ }
438
+
439
+ // 判据 D 比对(独立棘轮;基线缺该维度 → fail-closed,提示 --update)
440
+ const ruleBase = baseline.ruleLiteralFiles;
441
+ const ruleRegressions = [];
442
+ const ruleImprovements = [];
443
+ if (!ruleBase || typeof ruleBase !== "object") {
444
+ ruleRegressions.push("基线缺「规则号字面量」维度(ruleLiteralFiles)——判据 D 于 2026-09-10 新增,请运行 --update 记录基线");
445
+ } else {
446
+ for (const [file, count] of Object.entries(ruleCounts)) {
447
+ const b = ruleBase[file];
448
+ if (b === undefined) {
449
+ if (count > 0) ruleRegressions.push(`${file}: 新增文件含规则号字面量 ${count} 处(基线无此文件)`);
450
+ continue;
451
+ }
452
+ if (count > b) {
453
+ const r = results.find((x) => `lib/${x.relPath}` === file);
454
+ const detail = r ? r.ruleLiteralHits.slice(0, 2).join(";") : "";
455
+ ruleRegressions.push(`${file}: ${count} > 基线 ${b}${detail ? `(如:${detail})` : ""}`);
456
+ } else if (count < b) {
457
+ ruleImprovements.push(`${file}: ${count} < 基线 ${b}`);
458
+ }
459
+ }
460
+ for (const [file, b] of Object.entries(ruleBase)) {
461
+ if (ruleCounts[file] === undefined && b > 0) ruleImprovements.push(`${file}: 文件已删除(基线 ${b})`);
462
+ }
463
+ }
464
+
465
+ if (regressions.length > 0 || ruleRegressions.length > 0) {
466
+ if (regressions.length > 0) {
467
+ console.error(`DUALTRACK FAIL(判据 A 分层残留):${regressions.length} 项超出基线(只许降不许升)`);
468
+ for (const r of regressions) console.error(` ✗ ${r}`);
469
+ }
470
+ if (ruleRegressions.length > 0) {
471
+ console.error(`DUALTRACK FAIL(判据 D 规则号字面量):${ruleRegressions.length} 项超出基线(只许降不许升)`);
472
+ for (const r of ruleRegressions) console.error(` ✗ ${r}`);
473
+ }
474
+ console.error("\n 处置:分层残留 → 迁个人层/配置层,或(确属通用功能词)登记 scripts/dualtrack-whitelist.json");
475
+ console.error(" 规则号字面量 → 改为通用 kind 名(架构 v3 §三 L1);判据 D 不设白名单。");
476
+ process.exit(1);
477
+ }
478
+ const downCount = improvements.length + ruleImprovements.length;
479
+ console.log(`DUALTRACK OK:判据 A 合计 ${grandTotal}(基线 ${baseline.total ?? "?"})/判据 D 合计 ${ruleGrandTotal}(基线 ${baseline.ruleLiteralsTotal ?? "?"})${downCount ? `,${downCount} 个文件下降可 --update` : ""}`);
480
+ for (const im of [...improvements, ...ruleImprovements].slice(0, 5)) console.log(` ↓ ${im}`);
481
+ process.exit(0);
@@ -0,0 +1,7 @@
1
+ {
2
+ "note": "dualtrack-check 白名单。登记即豁免——但登记是一次「分类决策」,请写清理由。",
3
+ "files": [
4
+ "lib/lang/**"
5
+ ],
6
+ "strings": []
7
+ }
@@ -2,16 +2,22 @@
2
2
  // local-residue-scan.mjs — 发布物本机痕迹扫描(仅扫 lib/;无 --pack 模式)
3
3
  // 用法:node scripts/local-residue-scan.mjs (在包根目录运行;exit 0 = 干净,exit 1 = 有命中)
4
4
 
5
- import { readFileSync, readdirSync, statSync } from "node:fs";
5
+ import { readdirSync, statSync, readFileSync } from "node:fs";
6
6
  import { join, extname } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { dirname } from "node:path";
9
9
 
10
10
  const root = join(dirname(fileURLToPath(import.meta.url)), "..");
11
11
 
12
- // 词表唯一源(v1.2):scripts/local-residue-markers.txt,与阶段 A A6 验收共用同一文件。
13
- const MARKERS = readFileSync(join(root, "scripts", "local-residue-markers.txt"), "utf8")
14
- .split("\n").map((s) => s.trim()).filter((s) => s && !s.startsWith("#"));
12
+ // 词表唯一源(2026-09-09 起配置化):本机 rule-engine.jsondualtrack.markers 优先,
13
+ // 其次 DUALTRACK_MARKERS 环境变量,最后回退包内示例文件——见 lib/core/dualtrack-markers.js。
14
+ // 理由:词表是**发布者私有数据**,不应随公开仓库/包分发。
15
+ import { loadMarkers } from "../lib/core/dualtrack-markers.js";
16
+ const { markers: MARKERS, source: MARKERS_SOURCE } = loadMarkers({ root });
17
+ if (MARKERS.length === 0) {
18
+ console.error("REFUSED: 本机标识词表为空——请在 rule-engine.json 配置 dualtrack.markers,或设置 DUALTRACK_MARKERS 环境变量");
19
+ process.exit(1);
20
+ }
15
21
  // 注意:不扫 package.json(作者署名/仓库地址为合法项,A6 口径见其节);tool-catalog.js 的
16
22
  // dev_/esr_/engram_ 前缀规则按阶段 A「关键裁决 3」保留,静态枚举列 0.6.x 跟进。
17
23
 
@@ -0,0 +1,10 @@
1
+ {
2
+ "profile": "web",
3
+ "plugins": [
4
+ {
5
+ "name": "dsh-rule-engine",
6
+ "dir": ".",
7
+ "repo": "jilian-dsh/dsh-rule-engine"
8
+ }
9
+ ]
10
+ }
@@ -64,8 +64,11 @@ process.env.DSH_WORKSPACE = root;
64
64
  const plain = understandRule(arbitrary);
65
65
  check(plain.handler === "", "③ 任意编号 + 无声明:handler 为空(纯自证分流,不误绑定本机执行器)");
66
66
  check(plain.actions.includes("deny") && plain.actions.includes("self-certify"), "③ 任意编号:A+D 动作解析正确(deny+self-certify)");
67
- const declared = understandRule({ ...arbitrary, body: arbitrary.body + "\n<!-- handler: rule12a-approval -->" });
68
- check(declared.handler === "rule12a-approval", " 任意编号 + 声明:绑定指定执行器");
67
+ // 架构 v3 P1:声明值统一归一为 kind 名——kind 名(陌生用户视角)与历史内部名(兼容)都须绑定
68
+ const declaredKind = understandRule({ ...arbitrary, body: arbitrary.body + "\n<!-- handler: approval -->" });
69
+ check(declaredKind.handler === "approval", "③ 任意编号 + 声明(kind 名):绑定指定执行器");
70
+ const declaredLegacy = understandRule({ ...arbitrary, body: arbitrary.body + "\n<!-- handler: rule12a-approval -->" });
71
+ check(declaredLegacy.handler === "approval", "③ 任意编号 + 声明(历史内部名):归一为同一 kind");
69
72
  const covered = analyzeCoverage(understandAll([arbitrary]));
70
73
  check(covered.uncovered.some((u) => u.ruleId === "X-77"), "③ 任意编号 A 级无 handler:被标记未覆盖(提示正确)");
71
74
  // 通用化闭环:defaultMap 清空 → 陌生规则无兜底绑定(纯声明式世界,不借道本机偏好表);
@@ -163,6 +163,11 @@ if (dryRun) {
163
163
  const replaced = t.replaceAll(oldBadgeVer, nextUrl).replaceAll(oldBadgeUrl, nextUrl);
164
164
  console.log(`[DRY-RUN] README 徽章将变为: ${replaced.match(/version-[^\s]+/)?.[0] || "(未命中,检查徽章形态)"}`);
165
165
  }
166
+ // §1.2(2026-09-08)dry-run 可观测项(单测锚点)
167
+ console.log(`[DRY-RUN] plugins.json 条目: ${entry ? `${entry.name} (dir=${entry.dir})` : "(未命中——将回退目录路径参数)"}`);
168
+ console.log(`[DRY-RUN] dist-tags 轮询: ${Math.max(30, Number(process.env.RELEASE_DIST_TAG_POLL_SEC) || 180)}s(RELEASE_DIST_TAG_POLL_SEC 可配)`);
169
+ console.log(`[DRY-RUN] README 四点同步: 徽章 / 正文当前版本 / 历史表新行 / 固定源自动维护`);
170
+ console.log(`[DRY-RUN] 固定源将更新为: **${nextVer}(当前)** 锚点 ${quiet(`cd /d "${dir}" && git rev-parse HEAD`).trim().slice(0, 7)}(发布后自动回填 release 提交号)`);
166
171
  process.exit(0);
167
172
  }
168
173
  writeFileSync(pkgPath, readFileSync(pkgPath, "utf8").replace(`"version": "${oldVer}"`, `"version": "${nextVer}"`));
@@ -182,7 +187,32 @@ for (const readme of ["README.md", "README.en.md"]) {
182
187
  if (replaced !== text) writeFileSync(rp, replaced);
183
188
  }
184
189
  }
185
- console.log("版本已 bump(package.json + README 徽章)");
190
+ // §1.2-②(2026-09-08 第二批):README 正文"当前版本"行同步
191
+ // 方案 A(2026-09-09,踩坑 140 根治):固定源行**自动维护**——bump 时插入「(当前)」行(锚点=改号前 HEAD),
192
+ // 发布提交后由 §5.5 回填真实 release 提交号;旧「(当前)」标记与遗留占位注释一并清理。
193
+ // 旧做法只插占位注释,而 B1 门禁要求「固定源=package 版本」→ 每次发布必撞(0.6.3 首发实证)。
194
+ const headBeforeShort = quiet(`cd /d "${dir}" && git rev-parse HEAD`).trim().slice(0, 7);
195
+ let fixedSourceBumpLine = "";
196
+ for (const readme of ["README.md", "README.en.md"]) {
197
+ const rp = join(dir, readme);
198
+ if (!existsSync(rp)) continue;
199
+ let rtxt = readFileSync(rp, "utf8");
200
+ const before = rtxt;
201
+ rtxt = rtxt.replace(/(> 当前版本 \*\*)\d+\.\d+\.\d+(\*\*)/, `$1${nextVer}$2`);
202
+ if (readme === "README.md" && rtxt.includes("## 发行固定源")) {
203
+ // ① 旧「(当前)」标记降级(只保留版本号加粗)
204
+ rtxt = rtxt.replace(/\*\*(\d+\.\d+\.\d+)(当前)\*\*/g, "**$1**");
205
+ // ② 清理遗留占位注释(方案 A 起不再产生)
206
+ rtxt = rtxt.replace(/\n<!-- fixed-source: [^>]*-->\n/g, "\n");
207
+ // ③ 插入新版本行(幂等:已存在则跳过)
208
+ if (!rtxt.includes(`- **${nextVer}(当前)**`)) {
209
+ fixedSourceBumpLine = `- **${nextVer}(当前)** 固定于 main Commit \`${headBeforeShort}\`(\`git checkout ${headBeforeShort}\` + 发布 bump 可复现 npm \`${name}@${nextVer}\` 与 GitHub Release v${nextVer} 同源代码)。**注:release 提交号由发布脚本自动回填。**`;
210
+ rtxt = rtxt.replace("## 发行固定源\n", `## 发行固定源\n\n${fixedSourceBumpLine}\n`);
211
+ }
212
+ }
213
+ if (rtxt !== before) writeFileSync(rp, rtxt);
214
+ }
215
+ console.log(`版本已 bump(package.json + README 徽章 + 正文当前版本 + 固定源 ${nextVer}(锚点 ${headBeforeShort}))`);
186
216
 
187
217
  // B1(2026-09-03):README 版本表行自动插入(阶段 C 人脑核对已失效的自动化;幂等:已存在 nextVer 行则跳过)
188
218
  // 位置:版本表(| 版本 | 日期 | 要点 |)表头后第一数据行之前(最新在上);内容用调用方 notes 参数或 git log 最近提交信息
@@ -273,16 +303,18 @@ run(`cd /d "${dir}" && ${proxyPrefix()}npm publish ${tgz}`);
273
303
  // B1(2026-09-03):publish 后校验 registry dist-tags(0.5.16 事故:publish 自报成功但 latest 未切;
274
304
  // 脚本此前只跑 publish 不校验发布态——三通道验证铁律:npm 通道以 dist-tags 为准,self-report 不算数)
275
305
  // B1 v2(2026-09-03 同日晚):registry 传播以分钟计,publish 后立即校验曾误报中止(0.5.17 实弹)→
276
- // 轮询重试 60s(6×10s);仍不符才中止,并按"版本是否已落盘"给出人工修正路径
277
- console.log("\n=== npm 发布态校验(dist-tags,轮询最多 60s)===");
306
+ // §1.2-③(2026-09-08 第二批):轮询 60s→180s(本机首次传播实测 >60s);RELEASE_DIST_TAG_POLL_SEC 可配(下限 30)
307
+ const pollSec = Math.max(30, Number(process.env.RELEASE_DIST_TAG_POLL_SEC) || 180);
308
+ const attempts = Math.ceil(pollSec / 10);
309
+ console.log(`\n=== npm 发布态校验(dist-tags,轮询最多 ${pollSec}s / ${attempts} 次)===`);
278
310
  function sleepMs(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
279
311
  const queryTags = () => quiet(`cd /d "${dir}" && ${proxyPrefix()}npm view ${name} dist-tags.latest`);
280
312
  const queryVer = () => quiet(`cd /d "${dir}" && ${proxyPrefix()}npm view ${name}@${nextVer} version`);
281
313
  let latestActual = "";
282
- for (let i = 0; i < 6; i++) {
314
+ for (let i = 0; i < attempts; i++) {
283
315
  latestActual = queryTags();
284
316
  if (latestActual === nextVer) break;
285
- if (i < 5) { console.log(` [B1] 等待 registry 传播(${i + 1}/5,latest=${latestActual || "(空)"})…`); sleepMs(10000); }
317
+ if (i < attempts - 1) { console.log(` [B1] 等待 registry 传播(${i + 1}/${attempts - 1},latest=${latestActual || "(空)"})…`); sleepMs(10000); }
286
318
  }
287
319
  if (latestActual !== nextVer) {
288
320
  const verExists = queryVer() === nextVer;
@@ -326,6 +358,23 @@ run(`cd /d "${repoRoot}" && ${proxyPrefix()}git add -A -- ${stageSpec}`);
326
358
  run(`cd /d "${repoRoot}" && git -c core.autocrlf=false commit -m "release: ${name} v${nextVer}" || exit 0`);
327
359
  run(`cd /d "${repoRoot}" && ${proxyPrefix()}git push "${pushUrl}" HEAD`);
328
360
 
361
+ // ── 5.5 固定源回填(方案 A,2026-09-09):用 release 提交号替换 bump 时的锚点 ──
362
+ if (fixedSourceBumpLine) {
363
+ const relShort = quiet(`cd /d "${repoRoot}" && git rev-parse HEAD`).trim().slice(0, 7);
364
+ const rd = join(dir, "README.md");
365
+ const rtxt = existsSync(rd) ? readFileSync(rd, "utf8") : "";
366
+ const finalLine = `- **${nextVer}(当前)** 固定于 main Commit \`${relShort}\`(\`git checkout ${relShort}\` 可复现 npm \`${name}@${nextVer}\` 与 GitHub Release v${nextVer} 同源代码)。`;
367
+ if (rtxt.includes(fixedSourceBumpLine)) {
368
+ writeFileSync(rd, rtxt.replace(fixedSourceBumpLine, finalLine), "utf8");
369
+ console.log(`\n=== 固定源回填 ===\n${nextVer}:锚点 ${headBeforeShort} → release 提交 ${relShort}`);
370
+ run(`cd /d "${repoRoot}" && ${proxyPrefix()}git add -A -- ${stageSpec}`);
371
+ run(`cd /d "${repoRoot}" && git -c core.autocrlf=false commit -m "docs(readme): ${nextVer} 固定源回填 ${relShort}(发布后自动同步)" || exit 0`);
372
+ run(`cd /d "${repoRoot}" && ${proxyPrefix()}git push "${pushUrl}" HEAD`);
373
+ } else {
374
+ console.log("(固定源回填跳过:未匹配到 bump 行——可能已手工回填)");
375
+ }
376
+ }
377
+
329
378
  // ── 6. GitHub Release(带正式 tgz asset,规则 26;清单标记 skipRelease 的包不建)─────
330
379
  if (entry?.skipRelease) {
331
380
  console.log("(清单标记 skipRelease,跳过 GitHub Release)");
@@ -131,6 +131,10 @@ step("发布门禁 B1(README 版本四性一致性:package.json/徽章/正
131
131
  // ── ⑩ 发布门禁 B2(阶段 B,2026-09-04:lib/ 本机痕迹扫描,词表唯一源)──
132
132
  step("发布门禁 B2(lib/ 本机痕迹扫描——词表唯一源,命中即红)", process.execPath, [join(root, "scripts", "local-residue-scan.mjs")]);
133
133
 
134
+ // ── ⑩′ 发布门禁 B3:dualtrack-check(2026-09-09 第三批第 1 波:分层残留棘轮闸——
135
+ // 手册 SKILL.md:113「机器校验(dualtrack-check)为验收兜底」的落地;只许降不许升)──
136
+ step("发布门禁 B3(dualtrack 分层残留棘轮——超出基线即红)", process.execPath, [join(root, "scripts", "dualtrack-check.mjs")]);
137
+
134
138
  // ── ⑪ 存在性扫描(泄露预防,2026-09-05:真实路径判据——REAL_PATHS_SCAN 指向扫描器,0 命中才算过;
135
139
  // 未设置=WARN(本机增强门禁,通用环境无此工具)──
136
140
  {