dsh-novel-writer 6.4.0 → 6.5.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/cordis.patch.yml +1 -1
- package/lib/core.js +362 -5
- package/lib/fixplan.js +186 -27
- package/lib/index.js +339 -69
- package/lib/lexicons/guards.js +568 -0
- package/package.json +2 -2
- package/server.json +2 -2
package/cordis.patch.yml
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# dsh-novel-writer
|
|
1
|
+
# dsh-novel-writer bundle patch: 19 tools (incl. novel_semantic_search, novel_lexicon) + local embedding engine.
|
|
2
2
|
# dsh-novel-writer bundle patch: mounts the novel-writing assistant plugin
|
|
3
3
|
# into the profile's loader tree. The entry's name is the plugin package
|
|
4
4
|
# itself, resolved from the profile's node_modules.
|
package/lib/core.js
CHANGED
|
@@ -17,6 +17,7 @@ import { createRequire } from "node:module";
|
|
|
17
17
|
import { checkForUpdate } from "./update-check.js";
|
|
18
18
|
import { computeBaseline, computeBaselineFromPerChapter, measureStyleMetrics, METRIC_LABELS, METRIC_ORDER } from "./style-metrics.js";
|
|
19
19
|
import { CULTURE_MARKERS, GENRE_MARKERS, THEME_MARKERS, DEFAULT_BANNED_WORDS, SPEECH_STYLE_RULES } from "./lexicons/markers.js";
|
|
20
|
+
import { COLLISION_GUARDS } from "./lexicons/guards.js";
|
|
20
21
|
// v5.0.0:系统提示词的「场景」枚举由 prompts.js 单一持有(core.js 只做合法性校验与持久化)
|
|
21
22
|
import { PROMPT_SCENES } from "./prompts.js";
|
|
22
23
|
import * as embedding from "./embedding.js";
|
|
@@ -2593,7 +2594,10 @@ export function normalizeSettingEntry(entry) {
|
|
|
2593
2594
|
for (const key of ["description", "traits", "relationships", "firstSeen", "notes", "owner", "status", "lastSeen", "day", "event", "chapter", "basis", "ritual"]) {
|
|
2594
2595
|
if (entry[key] !== void 0 && entry[key] !== null) out[key] = String(entry[key]);
|
|
2595
2596
|
}
|
|
2596
|
-
|
|
2597
|
+
// v6.5.0:`allowWords`(本书用词豁免)必须进这个白名单——否则它写盘生效、静音也生效,
|
|
2598
|
+
// 但 add/list/scan/detect 的**返回体里看不到它**,用户无从核对自己到底豁免了什么
|
|
2599
|
+
// (独立核对子代理实测:写盘完整、审计静音正常,而返回值里只有条目名)。
|
|
2600
|
+
for (const key of ["alias", "bannedWords", "allowWords"]) {
|
|
2597
2601
|
if (Array.isArray(entry[key])) out[key] = entry[key].map(String);
|
|
2598
2602
|
}
|
|
2599
2603
|
if (entry.recommended !== void 0 && entry.recommended !== null && typeof entry.recommended === "object") {
|
|
@@ -2678,7 +2682,7 @@ export function hintCulture(text) {
|
|
|
2678
2682
|
* userBannedWords:string[], defaultEnabled:boolean,
|
|
2679
2683
|
* sources:{userCount:number, defaultCount:number, total:number, hint:string|null}}}
|
|
2680
2684
|
*/
|
|
2681
|
-
export function resolveStyleRule(settings, chapterTexts = []) {
|
|
2685
|
+
export function resolveStyleRule(settings, chapterTexts = [], opts = {}) {
|
|
2682
2686
|
const worldviewList = Array.isArray(settings?.worldview) ? settings.worldview.filter((e) => e && typeof e === "object") : [];
|
|
2683
2687
|
const reversed = worldviewList.slice().reverse();
|
|
2684
2688
|
|
|
@@ -2760,6 +2764,34 @@ export function resolveStyleRule(settings, chapterTexts = []) {
|
|
|
2760
2764
|
// 替代词:用户登记的优先(放后面覆盖)
|
|
2761
2765
|
const recommended = { ...(defaultEnabled ? base.recommended : {}), ...userRecommended };
|
|
2762
2766
|
|
|
2767
|
+
// ⑥ v6.5.0:**逐词静音**(本书豁免)。
|
|
2768
|
+
//
|
|
2769
|
+
// 解决什么问题:词表是"猜"这本书的。但有些词在**这本书里就是对的**——
|
|
2770
|
+
// 修仙小说里的「修士」=修行者(语料 308 次全是这个义)、中文历史小说里真有一座「教堂」。
|
|
2771
|
+
// 此前用户**没有任何办法**告诉插件"这个我知道":只能每次手动忽略(下次还报)、
|
|
2772
|
+
// 或关掉整个用语检查(别的词也不查了)、或把词从表里删掉(**所有书**都不查了,影响面过大)。
|
|
2773
|
+
//
|
|
2774
|
+
// 静音只作用于**本书**(不提供全局静音):全局静音会静默削弱所有书的覆盖,
|
|
2775
|
+
// 而人很难记得自己什么时候加过;按书静音最坏只是"这本书里漏",看得见、摸得着。
|
|
2776
|
+
//
|
|
2777
|
+
// 过滤放在本函数(**单一裁决点**)而不是各消费方——这样开写包 / 审计 / 改稿台
|
|
2778
|
+
// 四个入口自动一致,不会出现"审计静音了但开写包还在警告"的分裂。
|
|
2779
|
+
const allow = new Set([
|
|
2780
|
+
...(Array.isArray(opts?.allowWords) ? opts.allowWords : []),
|
|
2781
|
+
// 用户登记的豁免:worldview 条目上的 allowWords(并集)。
|
|
2782
|
+
// 复用既有表而不是新开一张,这样 `novel_settings add category=worldview name="用词豁免"
|
|
2783
|
+
// allowWords=["修士"]` 就能静音,且**没有登记过 worldview 的书**也能用(加这一条即可)。
|
|
2784
|
+
// 该条目的 name 不含文化标记词,故不会影响 hintCulture 的基准判定。
|
|
2785
|
+
...worldviewList.flatMap((e) => (Array.isArray(e.allowWords) ? e.allowWords : []))
|
|
2786
|
+
].map((w) => String(w ?? "").trim()).filter((w) => w !== ""));
|
|
2787
|
+
const bannedWords = allow.size > 0 ? merged.filter((w) => !allow.has(w)) : merged;
|
|
2788
|
+
const speechStyleFiltered = (() => {
|
|
2789
|
+
if (allow.size === 0 || !speechStyle) return speechStyle;
|
|
2790
|
+
const out = { ...speechStyle };
|
|
2791
|
+
if (Array.isArray(out.honorBad)) out.honorBad = out.honorBad.filter((w) => !allow.has(w));
|
|
2792
|
+
return out;
|
|
2793
|
+
})();
|
|
2794
|
+
|
|
2763
2795
|
const cultureName = String(bannedEntry?.name ?? "") || String(speechEntry?.name ?? "") || base.culture;
|
|
2764
2796
|
// 语用规范条目名:沿用 index.js 既有口径(speechEntry.name 优先,缺则回落 cultureName)
|
|
2765
2797
|
const speechCultureName = String(speechEntry?.name ?? "") || cultureName;
|
|
@@ -2768,16 +2800,19 @@ export function resolveStyleRule(settings, chapterTexts = []) {
|
|
|
2768
2800
|
cultureSource,
|
|
2769
2801
|
cultureName,
|
|
2770
2802
|
speechCultureName,
|
|
2771
|
-
bannedWords
|
|
2803
|
+
bannedWords,
|
|
2772
2804
|
recommended,
|
|
2773
|
-
speechStyle,
|
|
2805
|
+
speechStyle: speechStyleFiltered,
|
|
2774
2806
|
userBannedWords,
|
|
2775
2807
|
defaultEnabled,
|
|
2776
2808
|
registered: worldviewList.length > 0,
|
|
2809
|
+
// v6.5.0:把静音结果一并回传——审计文案要能说清"为什么少报了几条"
|
|
2810
|
+
allowedWords: [...allow],
|
|
2777
2811
|
sources: {
|
|
2778
2812
|
userCount: userBannedWords.length,
|
|
2779
2813
|
defaultCount: defaultEnabled ? base.bannedWords.length : 0,
|
|
2780
|
-
total:
|
|
2814
|
+
total: bannedWords.length,
|
|
2815
|
+
muted: merged.length - bannedWords.length,
|
|
2781
2816
|
hint: hint ? hint.culture : null
|
|
2782
2817
|
}
|
|
2783
2818
|
};
|
|
@@ -3091,6 +3126,328 @@ export async function readBookAllText(root, book, exec, opts) {
|
|
|
3091
3126
|
// v3.9.5:opts.withTexts=true 时一并返回逐章文本,供 style_report 六维基线复用(避免全书二次读盘)
|
|
3092
3127
|
return opts?.withTexts === true ? { text, chapters, texts } : { text, chapters };
|
|
3093
3128
|
}
|
|
3129
|
+
// ============ v6.5.0:用语审计的**定位**与**子串守卫**与**正则安全** ============
|
|
3130
|
+
|
|
3131
|
+
/**
|
|
3132
|
+
* 找出包含 needle 的**全部行号**(升序、去重)。
|
|
3133
|
+
*
|
|
3134
|
+
* 行号口径 = **该文件内的真实行号**。这一条有历史教训(见 index.js 里那段
|
|
3135
|
+
* "旧实现把各章首尾拼成一份文本再取行号,报出的 Lx 既不是任何文件的真实行号、也不带章名"):
|
|
3136
|
+
* 必须先**逐章**取行号,再带章名合并;绝不可把多章拼成一份文本后取偏移。
|
|
3137
|
+
*/
|
|
3138
|
+
export function linesContaining(rawLines, needle) {
|
|
3139
|
+
const out = [];
|
|
3140
|
+
if (!needle || !Array.isArray(rawLines)) return out;
|
|
3141
|
+
for (let i = 0; i < rawLines.length; i += 1) {
|
|
3142
|
+
if (rawLines[i].includes(needle)) out.push(i + 1);
|
|
3143
|
+
}
|
|
3144
|
+
return out;
|
|
3145
|
+
}
|
|
3146
|
+
|
|
3147
|
+
/**
|
|
3148
|
+
* 行号数组 → 区间数组(合并相邻/近邻行,避免"第2行,第3行,第5行"这种啰嗦输出)。
|
|
3149
|
+
* `gap` = 允许合并的最大行距:0 表示仅相邻行合并;1 表示隔一行也并进来。
|
|
3150
|
+
* [2,3,5,6,7] gap=0 → [{from:2,to:3},{from:5,to:7}]
|
|
3151
|
+
*/
|
|
3152
|
+
export function mergeLineRanges(lineNos, gap = 0) {
|
|
3153
|
+
const sorted = [...new Set((Array.isArray(lineNos) ? lineNos : [])
|
|
3154
|
+
.filter((n) => Number.isInteger(n) && n > 0))].sort((a, b) => a - b);
|
|
3155
|
+
const out = [];
|
|
3156
|
+
for (const n of sorted) {
|
|
3157
|
+
const last = out[out.length - 1];
|
|
3158
|
+
if (last && n - last.to <= gap + 1) last.to = n;
|
|
3159
|
+
else out.push({ from: n, to: n });
|
|
3160
|
+
}
|
|
3161
|
+
return out;
|
|
3162
|
+
}
|
|
3163
|
+
|
|
3164
|
+
/** 区间 → 「第2-10行,第52行」;超出 max 段则截断并注明总段数。 */
|
|
3165
|
+
export function formatLineRanges(ranges, max = 8) {
|
|
3166
|
+
const list = Array.isArray(ranges) ? ranges : [];
|
|
3167
|
+
if (list.length === 0) return "";
|
|
3168
|
+
const seg = (r) => "第" + (r.from === r.to ? r.from : r.from + "-" + r.to) + "行";
|
|
3169
|
+
const head = list.slice(0, max).map(seg).join(",");
|
|
3170
|
+
return list.length > max ? head + ",等 " + list.length + " 段" : head;
|
|
3171
|
+
}
|
|
3172
|
+
|
|
3173
|
+
/**
|
|
3174
|
+
* 该命中是否为**子串巧合**(应当忽略)。
|
|
3175
|
+
*
|
|
3176
|
+
* 判据极简且可审计:词**紧邻的前/后一个字**是否落在守卫表里。
|
|
3177
|
+
* 例:「这[里正]是」的前一字是「这」→ 巧合;「[在下]面」的后一字是「面」→ 巧合。
|
|
3178
|
+
*
|
|
3179
|
+
* 纪律:**错填会吃掉真命中**(静默漏报),所以守卫表只收有实证的字(见 lexicons/guards.js)。
|
|
3180
|
+
*/
|
|
3181
|
+
export function isGuardedArtifact(word, line, index) {
|
|
3182
|
+
const g = COLLISION_GUARDS[word];
|
|
3183
|
+
if (!g || typeof line !== "string" || !Number.isInteger(index) || index < 0) return false;
|
|
3184
|
+
// ① 单字邻接(最常见:这里+正是 / 在+下面 / 大+人物)
|
|
3185
|
+
if (Array.isArray(g.notBefore) && index > 0) {
|
|
3186
|
+
const before = line[index - 1];
|
|
3187
|
+
if (before && g.notBefore.includes(before)) return true;
|
|
3188
|
+
}
|
|
3189
|
+
if (Array.isArray(g.notAfter)) {
|
|
3190
|
+
const after = line[index + word.length];
|
|
3191
|
+
if (after && g.notAfter.includes(after)) return true;
|
|
3192
|
+
}
|
|
3193
|
+
// ② 多字序列(单字判不到的:如「老夫老妻」——匹配后紧跟的是「老」,而「老夫老矣」是真用法,
|
|
3194
|
+
// 故不能简单把「老」放进 notAfter,必须判完整的「老妻」序列)
|
|
3195
|
+
if (Array.isArray(g.notAfterSeq)) {
|
|
3196
|
+
for (const s of g.notAfterSeq) {
|
|
3197
|
+
if (typeof s === "string" && s !== "" && line.startsWith(s, index + word.length)) return true;
|
|
3198
|
+
}
|
|
3199
|
+
}
|
|
3200
|
+
if (Array.isArray(g.notBeforeSeq)) {
|
|
3201
|
+
for (const s of g.notBeforeSeq) {
|
|
3202
|
+
if (typeof s === "string" && s !== "" && index >= s.length && line.slice(index - s.length, index) === s) return true;
|
|
3203
|
+
}
|
|
3204
|
+
}
|
|
3205
|
+
return false;
|
|
3206
|
+
}
|
|
3207
|
+
|
|
3208
|
+
/** 从模式里挑几个字面字符做探针输入(最多 max 个,含中英文与数字)。 */
|
|
3209
|
+
function pickProbeChars(pattern, max = 4) {
|
|
3210
|
+
const out = [];
|
|
3211
|
+
for (const ch of String(pattern ?? "")) {
|
|
3212
|
+
if (/[\u4e00-\u9fffA-Za-z0-9]/.test(ch) && !out.includes(ch)) out.push(ch);
|
|
3213
|
+
if (out.length >= max) break;
|
|
3214
|
+
}
|
|
3215
|
+
if (out.length === 0) out.push("a", "1");
|
|
3216
|
+
return out;
|
|
3217
|
+
}
|
|
3218
|
+
|
|
3219
|
+
/** 跑一次模式,返回毫秒(异常返回 -1)。 */
|
|
3220
|
+
function timeRegex(pattern, input) {
|
|
3221
|
+
const t0 = process.hrtime.bigint();
|
|
3222
|
+
try {
|
|
3223
|
+
new RegExp(pattern, "g").test(input);
|
|
3224
|
+
} catch {
|
|
3225
|
+
return -1;
|
|
3226
|
+
}
|
|
3227
|
+
return Number(process.hrtime.bigint() - t0) / 1e6;
|
|
3228
|
+
}
|
|
3229
|
+
|
|
3230
|
+
/**
|
|
3231
|
+
* **深度感知**扫描出「分组 + 紧跟的限定符」。
|
|
3232
|
+
*
|
|
3233
|
+
* 为什么不能用正则做这件事:`[^()\\]` 这类字符类**匹配不了嵌套的括号**,
|
|
3234
|
+
* 于是 `((a)*)*` 这种「括号套括号」的形态会被整体漏掉——独立验证代理找出的 8 个漏网模式里,
|
|
3235
|
+
* 就有 2 个正是这样躲过去的。所以这里手写一个小扫描器(栈 + 跳过转义与字符类)。
|
|
3236
|
+
*/
|
|
3237
|
+
function scanGroups(pattern) {
|
|
3238
|
+
const out = [];
|
|
3239
|
+
const stack = [];
|
|
3240
|
+
const p = String(pattern ?? "");
|
|
3241
|
+
for (let i = 0; i < p.length; i += 1) {
|
|
3242
|
+
const ch = p[i];
|
|
3243
|
+
if (ch === "\\") { i += 1; continue; }
|
|
3244
|
+
if (ch === "[") {
|
|
3245
|
+
// 跳过整个字符类(内部的 \] 不算结束)
|
|
3246
|
+
i += 1;
|
|
3247
|
+
while (i < p.length && p[i] !== "]") { if (p[i] === "\\") i += 1; i += 1; }
|
|
3248
|
+
continue;
|
|
3249
|
+
}
|
|
3250
|
+
if (ch === "(") { stack.push(i); continue; }
|
|
3251
|
+
if (ch === ")") {
|
|
3252
|
+
const start = stack.pop();
|
|
3253
|
+
if (start === undefined) continue;
|
|
3254
|
+
const q = p.slice(i + 1).match(/^(?:[+*]|\{\d+,?\d*\})/);
|
|
3255
|
+
out.push({ start, end: i, inner: p.slice(start + 1, i), quant: q ? q[0] : "" });
|
|
3256
|
+
}
|
|
3257
|
+
}
|
|
3258
|
+
return out;
|
|
3259
|
+
}
|
|
3260
|
+
|
|
3261
|
+
/** 分组内的分支是否**可能重叠**(任两个分支首字符相同即视为重叠,如 (a|a))。 */
|
|
3262
|
+
function hasOverlappingAlternation(inner) {
|
|
3263
|
+
const alts = String(inner ?? "").split("|");
|
|
3264
|
+
if (alts.length < 2) return false;
|
|
3265
|
+
const heads = alts.map((a) => a.replace(/^\^/, "").slice(0, 1)).filter((h) => h !== "");
|
|
3266
|
+
return new Set(heads).size < heads.length;
|
|
3267
|
+
}
|
|
3268
|
+
|
|
3269
|
+
/** 同一个原子被**两个以上无界量词**修饰(如 a*a* / [一二三]+[一二三]*)→ 多项式级回溯。 */
|
|
3270
|
+
function hasRepeatedUnboundedAtom(pattern) {
|
|
3271
|
+
const p = String(pattern ?? "");
|
|
3272
|
+
const counts = new Map();
|
|
3273
|
+
const re = /(\\.|\[(?:\\.|[^\]\\])*\]|[A-Za-z0-9\u4e00-\u9fff])([+*])/g;
|
|
3274
|
+
let m = null;
|
|
3275
|
+
while ((m = re.exec(p)) !== null) {
|
|
3276
|
+
const atom = m[1];
|
|
3277
|
+
counts.set(atom, (counts.get(atom) || 0) + 1);
|
|
3278
|
+
}
|
|
3279
|
+
for (const n of counts.values()) if (n >= 2) return true;
|
|
3280
|
+
return false;
|
|
3281
|
+
}
|
|
3282
|
+
|
|
3283
|
+
/**
|
|
3284
|
+
* **经验探测**:把模式在**短对抗输入**上跑两遍,用耗时增长判定是否存在指数回溯。
|
|
3285
|
+
*
|
|
3286
|
+
* 为什么不能只靠形状黑名单:独立验证代理实测 8 个形状(`(a|a)*b`、`(香|香)*上`、`(a{0,10}){0,10}b`、
|
|
3287
|
+
* `a*a*…*b`、`((a)*)*b`、`(?:a|a)*b` …)**全部绕过了最初的嵌套量词检查**,而它们在 30 字输入上要跑 7.5 秒、
|
|
3288
|
+
* 33 字上超过 45 秒。形状枚举永远追不上——**必须实测**。
|
|
3289
|
+
*
|
|
3290
|
+
* 为什么探测本身不会挂住:指数回溯的代价随长度**指数增长**,而 n=18/22 时最坏也只有 2^21 ≈ 2×10^6 步
|
|
3291
|
+
* (实测同类模式 20 字 ~10ms、24 字 ~120ms、30 字 7.5 秒)。所以短输入上探测是安全的,
|
|
3292
|
+
* 而线性模式的耗时是**微秒级**——两者差三四个数量级,判定极稳。
|
|
3293
|
+
*
|
|
3294
|
+
* 两个判据取其一即拒绝:**22 字耗时 ≥ 5ms**(绝对量级),或 **22 字/18 字增长 ≥ 4 倍**(增长形态)。
|
|
3295
|
+
* 用两条是因为首次测量会含正则编译开销(约 1~3ms),会稀释比值——绝对量级那条把它兜住。
|
|
3296
|
+
*
|
|
3297
|
+
* **已知残留(如实记录)**:多项式级(如 5 个以上 `a*` 串联)在 22 字下确实很快(<1ms),
|
|
3298
|
+
* 本探测抓不到,靠 `hasRepeatedUnboundedAtom` 的结构检查兜。但**两者都不是完备的**——
|
|
3299
|
+
* 理论上仍存在既非指数、又无重复原子、却在长文本上很慢的模式。故运行期还有第三道:逐句扫描 + 长度上限。
|
|
3300
|
+
*/
|
|
3301
|
+
function probeReDoS(pattern) {
|
|
3302
|
+
let worstMs = 0;
|
|
3303
|
+
let worstRatio = 0;
|
|
3304
|
+
let sample = "";
|
|
3305
|
+
for (const ch of pickProbeChars(pattern)) {
|
|
3306
|
+
const t18 = timeRegex(pattern, ch.repeat(18));
|
|
3307
|
+
const t22 = timeRegex(pattern, ch.repeat(22));
|
|
3308
|
+
if (t18 < 0 || t22 < 0) return { worstMs: 0, worstRatio: 0, sample: ch, failed: true };
|
|
3309
|
+
const ms = Math.max(t18, t22);
|
|
3310
|
+
if (ms > worstMs) { worstMs = ms; sample = ch; }
|
|
3311
|
+
if (t18 >= 1 && t22 >= 1) {
|
|
3312
|
+
const ratio = t22 / t18;
|
|
3313
|
+
if (ratio > worstRatio) worstRatio = ratio;
|
|
3314
|
+
}
|
|
3315
|
+
}
|
|
3316
|
+
return { worstMs, worstRatio, sample, failed: false };
|
|
3317
|
+
}
|
|
3318
|
+
|
|
3319
|
+
/**
|
|
3320
|
+
* 正则**安全校验**(静态 + 经验,登记时用)。
|
|
3321
|
+
*
|
|
3322
|
+
* 为什么必须有:用户登记的语用模式会被 `new RegExp(p, "g")` 原样编译并执行,
|
|
3323
|
+
* 而 Node 的同步正则**无法中断**——不能在运行期"跑 50ms 就掐掉"。
|
|
3324
|
+
* 实测(29 字符输入):`/(上+)+香/` 18.4 秒、`/(上*)*香/` **40.8 秒**、`/上{0,20}香/` 0.05 毫秒。
|
|
3325
|
+
* 也就是说一个写法有毒的模式能让宿主长时间无响应,而它只是"世界观里的一条仪式禁式"。
|
|
3326
|
+
*
|
|
3327
|
+
* 三层:① 形状黑名单(嵌套量词 / 量词套在含分支的分组上 / 无界通配)
|
|
3328
|
+
* ② **经验探测**(形状枚举追不上的那些,靠实测耗时增长抓)
|
|
3329
|
+
* ③ 运行期逐句扫描(把跨句与单次输入规模限住,见 splitSentences)
|
|
3330
|
+
*
|
|
3331
|
+
* @returns {{ok: boolean, level: "ok"|"warn"|"reject", reason: string, suggestion: string}}
|
|
3332
|
+
*/
|
|
3333
|
+
export function validatePattern(pattern) {
|
|
3334
|
+
const p = typeof pattern === "string" ? pattern : "";
|
|
3335
|
+
if (p.trim() === "") return { ok: false, level: "reject", reason: "模式为空", suggestion: "" };
|
|
3336
|
+
if (p.length > 200) {
|
|
3337
|
+
return { ok: false, level: "reject", reason: "模式超过 200 字符上限", suggestion: "拆成多条更短的模式" };
|
|
3338
|
+
}
|
|
3339
|
+
try {
|
|
3340
|
+
new RegExp(p, "g");
|
|
3341
|
+
} catch (e) {
|
|
3342
|
+
return { ok: false, level: "reject", reason: "正则语法错误:" + String(e && e.message || e).slice(0, 120), suggestion: "检查括号与转义是否配对" };
|
|
3343
|
+
}
|
|
3344
|
+
|
|
3345
|
+
// ①-a / ①-b 分组上的量词:用**深度感知扫描**(正则匹配不了嵌套括号,会漏掉 ((a)*)* 这类)
|
|
3346
|
+
// · 分组内含**量词** + 外层有量词 → 嵌套重复((x+)+ / ((a)*)* / (a{0,10}){0,10})
|
|
3347
|
+
// · 分组内含**可能重叠的分支** + 外层有量词 → 重叠分支回溯((a|a)* / (香|香)*)
|
|
3348
|
+
// 注意 (上|下)+ 这种**不重叠**的分支是安全的,不误拒(判据:任两分支首字符是否相同)
|
|
3349
|
+
for (const g of scanGroups(p)) {
|
|
3350
|
+
if (!g.quant) continue;
|
|
3351
|
+
const innerHasQuant = /[+*]|\{\d+,?\d*\}/.test(g.inner);
|
|
3352
|
+
if (innerHasQuant) {
|
|
3353
|
+
const atom = g.inner.replace(/[+*]|\{\d+,?\d*\}/g, "").replace(/[()]/g, "");
|
|
3354
|
+
const demo = atom ? atom + "{0,20}" : "把内层量词改成有界(如 {0,20})";
|
|
3355
|
+
return {
|
|
3356
|
+
ok: false, level: "reject",
|
|
3357
|
+
reason: "量词套在含量词的分组上(嵌套重复),会造成指数级回溯——实测同类模式在 29 字符上要跑 40 秒",
|
|
3358
|
+
suggestion: "把内层重复改成有界量词,例如 " + demo + ";或去掉外层的重复"
|
|
3359
|
+
};
|
|
3360
|
+
}
|
|
3361
|
+
if (hasOverlappingAlternation(g.inner)) {
|
|
3362
|
+
return {
|
|
3363
|
+
ok: false, level: "reject",
|
|
3364
|
+
reason: "量词套在**分支重叠**的分组上(如 (a|a)*),会造成指数级回溯——实测同类模式在 30 字符上要跑 7.5 秒",
|
|
3365
|
+
suggestion: "合并重复的分支((a|a) → a),或把外层量词改成有界(如 {0,20})"
|
|
3366
|
+
};
|
|
3367
|
+
}
|
|
3368
|
+
}
|
|
3369
|
+
|
|
3370
|
+
// ①-c 同一原子被两个以上无界量词修饰:a*a*…*b 属**多项式**级回溯,
|
|
3371
|
+
// 在 22 字下确实很快(<1ms),经验探测抓不到,但长文本上仍会炸(n^6 量级)
|
|
3372
|
+
if (hasRepeatedUnboundedAtom(p)) {
|
|
3373
|
+
return {
|
|
3374
|
+
ok: false, level: "reject",
|
|
3375
|
+
reason: "同一个原子被两个以上无界量词修饰(如 a*a*a*),长文本上会造成多项式级回溯",
|
|
3376
|
+
suggestion: "合并成一个有界量词,例如 a*a* → a{0,20}"
|
|
3377
|
+
};
|
|
3378
|
+
}
|
|
3379
|
+
|
|
3380
|
+
// ①-d 无界通配:跨句匹配(审计是整章文本,会跨越句号与换行)
|
|
3381
|
+
if (/\.\*|\[\\s\\S\]\*|\[\\s\\S\]\+|\.\{\d+,\}/.test(p)) {
|
|
3382
|
+
return {
|
|
3383
|
+
ok: false, level: "reject",
|
|
3384
|
+
reason: "无界通配(.* / [\\s\\S]* 等)会跨越句号与换行,把不相干的上下文连起来误报",
|
|
3385
|
+
suggestion: "改成句内且有界的间隔,例如 .* → [^。!?\\n]{0,20}"
|
|
3386
|
+
};
|
|
3387
|
+
}
|
|
3388
|
+
|
|
3389
|
+
// ② 经验探测(形状枚举追不上的那些,靠实测抓)
|
|
3390
|
+
const probe = probeReDoS(p);
|
|
3391
|
+
if (probe.failed) {
|
|
3392
|
+
return { ok: false, level: "reject", reason: "模式在执行时抛错,无法用于扫描", suggestion: "检查模式的转义与分组" };
|
|
3393
|
+
}
|
|
3394
|
+
if (probe.worstMs >= 5 || (probe.worstRatio >= 4 && probe.worstMs >= 1)) {
|
|
3395
|
+
return {
|
|
3396
|
+
ok: false, level: "reject",
|
|
3397
|
+
reason: "实测该模式存在回溯爆炸(对抗输入 22 字耗时 " + probe.worstMs.toFixed(1) + "ms" +
|
|
3398
|
+
(probe.worstRatio >= 4 ? "、增长 " + probe.worstRatio.toFixed(1) + " 倍" : "") + "),长文本上会让检查长时间卡住",
|
|
3399
|
+
suggestion: "把重复改成有界量词(如 {0,20})、去掉重叠的分支((a|a) → a)、或避免量词套量词"
|
|
3400
|
+
};
|
|
3401
|
+
}
|
|
3402
|
+
|
|
3403
|
+
// ③ 顶层无界重复:线性但不封顶,长文本上代价不可控
|
|
3404
|
+
if (/\{\d+,\}/.test(p)) {
|
|
3405
|
+
return {
|
|
3406
|
+
ok: false, level: "warn",
|
|
3407
|
+
reason: "存在无上界的重复次数({n,}),长文本上代价不可控",
|
|
3408
|
+
suggestion: "给出上界,例如 {1,} → {1,20}"
|
|
3409
|
+
};
|
|
3410
|
+
}
|
|
3411
|
+
|
|
3412
|
+
// ④ 边界锚:本审计是"在正文里找可疑片段",锚定整串的模式几乎必然匹配不到
|
|
3413
|
+
if (/^\^|\$$/.test(p)) {
|
|
3414
|
+
return { ok: false, level: "warn", reason: "模式带有整串锚定(^ / $),在正文扫描里通常匹配不到", suggestion: "去掉 ^ 与 $,或改用更小的片段作为模式" };
|
|
3415
|
+
}
|
|
3416
|
+
|
|
3417
|
+
return { ok: true, level: "ok", reason: "", suggestion: "" };
|
|
3418
|
+
}
|
|
3419
|
+
|
|
3420
|
+
/**
|
|
3421
|
+
* 把章节文本切成**句级**片段(正则扫描的单位)。
|
|
3422
|
+
*
|
|
3423
|
+
* 为什么必须逐句:模式若含 `.` 类通配,在整章文本上会跨越句号与换行——
|
|
3424
|
+
* 实测 `/上.*香/` 在一段 121 字的文本上命中了 115 字(跨 3 句)。逐句后这种跨句在结构上不可能。
|
|
3425
|
+
* 同时它把单次匹配的输入规模限住,是 ReDoS 的第二道防线(第一道是 validatePattern 的静态+经验校验)。
|
|
3426
|
+
*
|
|
3427
|
+
* 超长"句子"(无标点的长段落)按 `hardLimit` 硬切。**硬切必须带重叠**:
|
|
3428
|
+
* 独立验证代理实测,不带重叠时「上……香 相距 6 字但跨过 1000 字切点」的那条命中**整条丢失**
|
|
3429
|
+
* (对照组正常命中)。重叠 `overlap` 字即可让跨切点的模式在相邻窗口里被完整看到;
|
|
3430
|
+
* 重复命中由调用方按行号去重(locations 是行号区间,天然去重)。
|
|
3431
|
+
*
|
|
3432
|
+
* 上限从 1000 收到 400:句子通常远短于此,而更小的单位让"万一漏过校验的有毒模式"
|
|
3433
|
+
* 也炸不起来(同步正则无法中断,只能靠限制输入规模)。
|
|
3434
|
+
*/
|
|
3435
|
+
export function splitSentences(text, hardLimit = 400, overlap = 32) {
|
|
3436
|
+
const src = typeof text === "string" ? text : "";
|
|
3437
|
+
if (src === "") return [];
|
|
3438
|
+
const step = Math.max(1, hardLimit - overlap);
|
|
3439
|
+
const out = [];
|
|
3440
|
+
for (const raw of src.split(/(?<=[。!?!?;;\n])/)) {
|
|
3441
|
+
if (raw === "") continue;
|
|
3442
|
+
if (raw.length <= hardLimit) { out.push(raw); continue; }
|
|
3443
|
+
for (let i = 0; i < raw.length; i += step) {
|
|
3444
|
+
out.push(raw.slice(i, i + hardLimit));
|
|
3445
|
+
if (i + hardLimit >= raw.length) break;
|
|
3446
|
+
}
|
|
3447
|
+
}
|
|
3448
|
+
return out;
|
|
3449
|
+
}
|
|
3450
|
+
|
|
3094
3451
|
export async function buildStyleAnchorPackage(root, book, chapters, exec, preloadedText) {
|
|
3095
3452
|
// v4.3.0 修正:预读文本是必需契约(root/book/chapters 形参保留仅为兼容 index.js 的位置调用,函数内已不用)。
|
|
3096
3453
|
// 选择"删除磁盘兜底 + assert"而不是"补测试覆盖":唯一可达的兜底场景是 preloadedText === ""(整本书都是
|