dsh-rule-engine 0.3.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/LICENSE +21 -0
- package/README.md +132 -0
- package/cordis.patch.yml +7 -0
- package/lib/core/audit.js +44 -0
- package/lib/core/authorization.js +227 -0
- package/lib/core/config.js +30 -0
- package/lib/core/guard-core.js +453 -0
- package/lib/core/llm-understander.js +104 -0
- package/lib/core/matcher.js +50 -0
- package/lib/core/mount-signature.js +87 -0
- package/lib/core/parser.js +101 -0
- package/lib/core/paths.js +24 -0
- package/lib/core/patterns.js +351 -0
- package/lib/core/runtime.js +5 -0
- package/lib/core/silent-error.js +44 -0
- package/lib/core/state.js +185 -0
- package/lib/core/text-detect.js +185 -0
- package/lib/core/understander.js +127 -0
- package/lib/core/understanding-store.js +27 -0
- package/lib/core/version-guard.js +117 -0
- package/lib/index.js +648 -0
- package/lib/service.js +174 -0
- package/package.json +48 -0
- package/scripts/audit-mount-consistency.mjs +198 -0
- package/scripts/build.sh +13 -0
- package/scripts/check-real.mjs +24 -0
- package/upgrade-impact.json +55 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// state.js - 插件运行时状态(内存态)。
|
|
2
|
+
// 所有会话级状态以 sessionId 为 key;turn 级状态在 turn/start 重置。
|
|
3
|
+
import { statSync } from "node:fs";
|
|
4
|
+
import { loadRules } from "./parser.js";
|
|
5
|
+
import { agentsFilePath } from "./paths.js";
|
|
6
|
+
import { understandAll } from "./understander.js";
|
|
7
|
+
import { AUTH_TTL_MS } from "./authorization.js";
|
|
8
|
+
|
|
9
|
+
const MAX_SESSIONS = 200;
|
|
10
|
+
const MAX_RETRY_KEYS = 500;
|
|
11
|
+
const MAX_INJECT_KEYS = 500;
|
|
12
|
+
const MTIME_CHECK_INTERVAL_MS = 2000;
|
|
13
|
+
|
|
14
|
+
export function createState() {
|
|
15
|
+
return {
|
|
16
|
+
loadedAt: 0,
|
|
17
|
+
enabled: true,
|
|
18
|
+
rules: [],
|
|
19
|
+
configs: [],
|
|
20
|
+
configOk: true,
|
|
21
|
+
configError: null,
|
|
22
|
+
bypassUntil: 0,
|
|
23
|
+
unlockUntil: 0,
|
|
24
|
+
mtimeMs: 0,
|
|
25
|
+
lastMtimeCheck: 0,
|
|
26
|
+
sessions: new Map(),
|
|
27
|
+
retryCounts: new Map(),
|
|
28
|
+
injectCounts: new Map(),
|
|
29
|
+
globalAuthorizations: [],
|
|
30
|
+
skillNames: null,
|
|
31
|
+
llmEnrichedKeys: new Set(),
|
|
32
|
+
lastActive: [],
|
|
33
|
+
lastEvent: null,
|
|
34
|
+
reloadCount: 0,
|
|
35
|
+
mountRevision: 0,
|
|
36
|
+
mountSignature: ""
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** AGENTS.md mtime 变化时自动重解析(规则 21:规则是流动数据) */
|
|
41
|
+
export function maybeReloadIfChanged(state, now = Date.now()) {
|
|
42
|
+
if (now - state.lastMtimeCheck < MTIME_CHECK_INTERVAL_MS) return false;
|
|
43
|
+
state.lastMtimeCheck = now;
|
|
44
|
+
pruneState(state);
|
|
45
|
+
try {
|
|
46
|
+
const mtime = statSync(agentsFilePath()).mtimeMs;
|
|
47
|
+
if (mtime !== state.mtimeMs) {
|
|
48
|
+
reloadRules(state);
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
// AGENTS.md 暂时不可读时保持旧规则,不崩
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 清理过期/超量内存状态:会话、重试计数、注入计数 */
|
|
58
|
+
export function pruneState(state) {
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
if (state.sessions.size > MAX_SESSIONS) {
|
|
61
|
+
const entries = [...state.sessions.entries()].sort((a, b) => (a[1].lastSeen || 0) - (b[1].lastSeen || 0));
|
|
62
|
+
const excess = entries.slice(0, state.sessions.size - MAX_SESSIONS);
|
|
63
|
+
for (const [key] of excess) state.sessions.delete(key);
|
|
64
|
+
}
|
|
65
|
+
if (state.retryCounts.size > MAX_RETRY_KEYS) {
|
|
66
|
+
const keys = [...state.retryCounts.keys()];
|
|
67
|
+
for (let i = 0; i < keys.length - MAX_RETRY_KEYS; i++) state.retryCounts.delete(keys[i]);
|
|
68
|
+
}
|
|
69
|
+
if (state.injectCounts.size > MAX_INJECT_KEYS) {
|
|
70
|
+
const keys = [...state.injectCounts.keys()];
|
|
71
|
+
for (let i = 0; i < keys.length - MAX_INJECT_KEYS; i++) state.injectCounts.delete(keys[i]);
|
|
72
|
+
}
|
|
73
|
+
// 清理过期授权(顺便释放内存)
|
|
74
|
+
for (const s of state.sessions.values()) {
|
|
75
|
+
s.authorizations = s.authorizations.filter((a) => !a.expiresAt || a.expiresAt > now);
|
|
76
|
+
}
|
|
77
|
+
if (Array.isArray(state.globalAuthorizations)) {
|
|
78
|
+
state.globalAuthorizations = state.globalAuthorizations.filter((a) => !a.expiresAt || a.expiresAt > now);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function getSessionState(state, sessionId) {
|
|
83
|
+
const key = sessionId || "global";
|
|
84
|
+
let s = state.sessions.get(key);
|
|
85
|
+
if (!s) {
|
|
86
|
+
s = {
|
|
87
|
+
id: key,
|
|
88
|
+
manualReadSeen: false,
|
|
89
|
+
lastUserText: "",
|
|
90
|
+
selfCertCount: new Map(),
|
|
91
|
+
authorizations: [],
|
|
92
|
+
backups: [],
|
|
93
|
+
mountAuditRevision: 0,
|
|
94
|
+
mountAuditSignature: "",
|
|
95
|
+
lastCommandOutput: "",
|
|
96
|
+
lastSeen: Date.now(),
|
|
97
|
+
turn: freshTurn()
|
|
98
|
+
};
|
|
99
|
+
state.sessions.set(key, s);
|
|
100
|
+
}
|
|
101
|
+
s.lastSeen = Date.now();
|
|
102
|
+
return s;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 记录一条授权证据(结构化:type/pathPrefix/at/source/expiresAt)。同时写入全局池,便于子代理/跨会话工具调用复用。 */
|
|
106
|
+
export function recordAuthorization(state, sessionId, auth) {
|
|
107
|
+
const record = {
|
|
108
|
+
at: Date.now(),
|
|
109
|
+
source: "ask",
|
|
110
|
+
expiresAt: Date.now() + AUTH_TTL_MS,
|
|
111
|
+
...auth
|
|
112
|
+
};
|
|
113
|
+
const s = getSessionState(state, sessionId);
|
|
114
|
+
s.authorizations.push(record);
|
|
115
|
+
state.globalAuthorizations.push(record);
|
|
116
|
+
return s.authorizations;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 记录一条备份证据(目标路径 → 备份路径) */
|
|
120
|
+
export function recordBackup(state, sessionId, targetPath, backupPath) {
|
|
121
|
+
const s = getSessionState(state, sessionId);
|
|
122
|
+
s.backups.push({
|
|
123
|
+
targetPath: normalizeBackupPath(targetPath),
|
|
124
|
+
backupPath: normalizeBackupPath(backupPath),
|
|
125
|
+
at: Date.now()
|
|
126
|
+
});
|
|
127
|
+
return s.backups;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function normalizeBackupPath(p) {
|
|
131
|
+
return String(p || "").replace(/\\/g, "/").toLowerCase();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 查找目标路径是否已有对应备份 */
|
|
135
|
+
export function findBackupForPath(state, sessionId, targetPath) {
|
|
136
|
+
const s = getSessionState(state, sessionId);
|
|
137
|
+
const t = normalizeBackupPath(targetPath);
|
|
138
|
+
return s.backups.find((b) => b.targetPath === t) || null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function freshTurn() {
|
|
142
|
+
return {
|
|
143
|
+
number: 0,
|
|
144
|
+
userText: "",
|
|
145
|
+
askSeen: false,
|
|
146
|
+
questionOnly: false,
|
|
147
|
+
pendingAsk: null,
|
|
148
|
+
pendingToolCalls: new Map(),
|
|
149
|
+
getDateSeen: false,
|
|
150
|
+
backupSeen: false,
|
|
151
|
+
toolCount: 0,
|
|
152
|
+
firstToolName: "",
|
|
153
|
+
skillNames: [],
|
|
154
|
+
toolNames: [],
|
|
155
|
+
commands: [],
|
|
156
|
+
reasoningText: ""
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function resetTurn(state, sessionId, turnNumber) {
|
|
161
|
+
const s = getSessionState(state, sessionId);
|
|
162
|
+
s.turn = freshTurn();
|
|
163
|
+
s.turn.number = typeof turnNumber === "number" ? turnNumber : 0;
|
|
164
|
+
s.turn.userText = s.lastUserText || "";
|
|
165
|
+
return s.turn;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** 重新加载并理解 AGENTS.md */
|
|
169
|
+
export function reloadRules(state) {
|
|
170
|
+
const parsed = loadRules();
|
|
171
|
+
state.rules = parsed.ok ? parsed.rules : [];
|
|
172
|
+
state.configs = understandAll(state.rules);
|
|
173
|
+
state.configOk = parsed.ok;
|
|
174
|
+
state.configError = parsed.error || null;
|
|
175
|
+
state.mtimeMs = parsed.mtimeMs || 0;
|
|
176
|
+
state.loadedAt = Date.now();
|
|
177
|
+
state.lastMtimeCheck = Date.now();
|
|
178
|
+
state.reloadCount++;
|
|
179
|
+
return state;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 获取某条理解配置 */
|
|
183
|
+
export function findConfig(state, ruleId) {
|
|
184
|
+
return state.configs.find((c) => String(c.ruleId) === String(ruleId));
|
|
185
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// text-detect.js - 输出文本检测(B 级纠察 + 部分 D 级自证触发)。
|
|
2
|
+
// 官方架构下 assistant/message 无法拦下不发,因此这里做「必发现、必记账、可注入纠正」。
|
|
3
|
+
import {
|
|
4
|
+
CJK_RE,
|
|
5
|
+
PROMISE_WORDS,
|
|
6
|
+
SOURCE_MARK,
|
|
7
|
+
TIME_WORDS,
|
|
8
|
+
URL_RE
|
|
9
|
+
} from "./patterns.js";
|
|
10
|
+
|
|
11
|
+
/** 从 assistant message 内容中提取纯文本 */
|
|
12
|
+
export function extractAssistantText(message) {
|
|
13
|
+
if (!message) return "";
|
|
14
|
+
const content = message.content;
|
|
15
|
+
if (typeof content === "string") return content;
|
|
16
|
+
if (Array.isArray(content)) {
|
|
17
|
+
return content
|
|
18
|
+
.map((b) => (b && typeof b === "object" && b.type === "text" ? b.text : ""))
|
|
19
|
+
.filter(Boolean)
|
|
20
|
+
.join("\n");
|
|
21
|
+
}
|
|
22
|
+
return "";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const SELF_CERT_HINTS = {
|
|
26
|
+
"14": { re: /总结|汇报|完成/, reason: "请按规则 14 一次性完整汇报,区分事实与推断" },
|
|
27
|
+
"22": { re: /我记下了|记住了|收到,我记下了|放心,我记住了/i, reason: "检测到「我记下了」类空话;正确动作是落盘执行并汇报" },
|
|
28
|
+
"23": { re: /完成|交付/, reason: "交付/完成声明未附运行时验证证据" },
|
|
29
|
+
"6": { re: /(?:API|JSON|正则|异步|依赖注入|回调|schema|DI)/i, reason: "请按规则 6 用零基础用户能懂的语言解释" },
|
|
30
|
+
"16": { re: /建议|优化|更优方案/, reason: "请按规则 16 用五要素格式提建议,避免频繁打断" },
|
|
31
|
+
"20": { re: /推理档位|off|high|max/, reason: "请按规则 20 给出档位建议并说明理由" },
|
|
32
|
+
"12E": { re: /重启|GUI|登录|界面|窗口/, reason: "请按规则 12E 先确认界面真实存在并声明能力边界" },
|
|
33
|
+
"26": { re: /(?=发布|Release|附件)(?!(?:Attach binaries|正式 Asset|release\.assets))/i, reason: "请按规则 26 确认正式 Release Asset(release.assets)并附 tgz/zip" },
|
|
34
|
+
"10": { re: /新会话|旧会话|旧项目|上下文恢复/, reason: "请按规则 10 自证:如实说明默认不自动继承,按优先级恢复并标注来源" },
|
|
35
|
+
"12C": { re: /下载|网络|curl|Clash|被墙/, reason: "请按规则 12C 自证:需代理先告知、失败即停、下载后校验、排查先测连通性" },
|
|
36
|
+
"13B": { re: /会话文件|会话修复|fromRestore|Inbox 回放|孤儿扫描/, reason: "请按规则 13B 自证:会话替换前用户完全退出、三层验证一次跑完、mtime 判断" },
|
|
37
|
+
"15": { re: /文件版本|文件用途|新旧|版本.*处置|保留文件/, reason: "请按规则 15 自证:不凭 mtime/文件名判断新旧,无法确定版本/用途时保留并询问" },
|
|
38
|
+
"19": { re: /学到新.*DSH|踩到新坑|踩坑.*沉淀|手册有误|知识必沉淀/, reason: "请按规则 19 自证:按①-⑦沉淀并当次汇报正文同步位置" }
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** 简单判断一段文本是否以英文为主 */
|
|
42
|
+
function isMostlyEnglish(text) {
|
|
43
|
+
if (!text) return false;
|
|
44
|
+
const letters = (text.match(/[A-Za-z]/g) || []).length;
|
|
45
|
+
const cjk = (text.match(CJK_RE) || []).length;
|
|
46
|
+
return letters > 30 && cjk === 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 检测一次 assistant/message 的 B/D 级违规。
|
|
51
|
+
* @param {object} options
|
|
52
|
+
* @param {Array} options.configs 理解配置
|
|
53
|
+
* @param {object} options.session 会话状态(getSessionState 返回)
|
|
54
|
+
* @param {string} options.text assistant 纯文本
|
|
55
|
+
* @returns {Array<{ruleId:string,title:string,kind:string,reason:string}>}
|
|
56
|
+
*/
|
|
57
|
+
export function detectViolations({ configs, session, text, reasoningText = "", mountRevision = 0 }) {
|
|
58
|
+
const hits = [];
|
|
59
|
+
const byId = new Map(configs.filter((c) => c.confidence !== "low").map((c) => [String(c.ruleId), c]));
|
|
60
|
+
|
|
61
|
+
const timeCfg = byId.get("2");
|
|
62
|
+
if (timeCfg && TIME_WORDS.test(text) && !session.turn.getDateSeen) {
|
|
63
|
+
hits.push({
|
|
64
|
+
ruleId: "2",
|
|
65
|
+
title: timeCfg.title,
|
|
66
|
+
kind: "correct",
|
|
67
|
+
reason: "回答出现时间词/日期,但本回合未先调用 Get-Date 核对"
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const promiseCfg = byId.get("7");
|
|
72
|
+
if (promiseCfg && PROMISE_WORDS.test(text)) {
|
|
73
|
+
hits.push({
|
|
74
|
+
ruleId: "7",
|
|
75
|
+
title: promiseCfg.title,
|
|
76
|
+
kind: "correct",
|
|
77
|
+
reason: "检测到绝对化承诺词,请改为保守表述"
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const sourceCfg = byId.get("5");
|
|
82
|
+
if (sourceCfg && URL_RE.test(text) && !SOURCE_MARK.test(text)) {
|
|
83
|
+
hits.push({
|
|
84
|
+
ruleId: "5",
|
|
85
|
+
title: sourceCfg.title,
|
|
86
|
+
kind: "correct",
|
|
87
|
+
reason: "回答包含 URL 但未标注出处/来源"
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const langCfg = byId.get("11");
|
|
92
|
+
const visibleEnglish = isMostlyEnglish(text);
|
|
93
|
+
const reasoningEnglish = isMostlyEnglish(reasoningText);
|
|
94
|
+
if (langCfg && session.lastUserText && CJK_RE.test(session.lastUserText) && (visibleEnglish || reasoningEnglish)) {
|
|
95
|
+
hits.push({
|
|
96
|
+
ruleId: "11",
|
|
97
|
+
title: langCfg.title,
|
|
98
|
+
kind: "correct",
|
|
99
|
+
reason: visibleEnglish
|
|
100
|
+
? "用户中文提问,回答几乎全英文"
|
|
101
|
+
: "用户中文提问,思维链(reasoning)几乎全英文"
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const directCfg = byId.get("22");
|
|
106
|
+
if (directCfg && /我记下了|记住了|收到,我记下了|放心,我记住了/i.test(text)) {
|
|
107
|
+
hits.push({
|
|
108
|
+
ruleId: "22",
|
|
109
|
+
title: directCfg.title,
|
|
110
|
+
kind: "self-certify",
|
|
111
|
+
reason: "检测到「我记下了」类空话;正确动作是落盘执行并汇报"
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const verifyCfg = byId.get("23");
|
|
116
|
+
if (verifyCfg && /完成|交付/.test(text) && !/运行时验证|mock|启动|实测|测试|验证/.test(text)) {
|
|
117
|
+
hits.push({
|
|
118
|
+
ruleId: "23",
|
|
119
|
+
title: verifyCfg.title,
|
|
120
|
+
kind: "self-certify",
|
|
121
|
+
reason: "交付/完成声明未附运行时验证证据"
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const mountCfg = byId.get("27");
|
|
126
|
+
if (mountCfg && mountRevision > (session.mountAuditRevision || 0) && /重启|装配|挂载/.test(text) && !/审计通过|全量审计|MOUNT CONSISTENT|DUPLICATES FOUND|audit-mount-consistency/.test(text)) {
|
|
127
|
+
hits.push({
|
|
128
|
+
ruleId: "27",
|
|
129
|
+
title: mountCfg.title,
|
|
130
|
+
kind: "self-certify",
|
|
131
|
+
reason: `规则 27:插件装配已变更(mountRevision=${mountRevision})且本会话未通过全量审计,重启/继续前请先运行 audit-mount-consistency.mjs`
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 规则 21:选项即边界——检测“补充/增加”类越界表述,未声明仅按勾选时触发自证
|
|
136
|
+
const scopeCfg = byId.get("21");
|
|
137
|
+
if (scopeCfg && /(?:我补充|顺便补充|额外补充|我增加|顺便加|额外加|多加|自行纳入)/.test(text) && !/仅按勾选|只实现被选项|未选项|单独确认|再次征求/.test(text)) {
|
|
138
|
+
hits.push({
|
|
139
|
+
ruleId: "21",
|
|
140
|
+
title: scopeCfg.title,
|
|
141
|
+
kind: "self-certify",
|
|
142
|
+
reason: "规则 21:检测到越界补充表述,请自证是否超出用户勾选范围;未选项需单独确认"
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 规则 22:被指出错误后需主动给出原因/改正/防再犯,不能只道歉
|
|
147
|
+
const errorCfg = byId.get("22");
|
|
148
|
+
if (errorCfg && /(?:抱歉|对不起|我错了|这是我的错|失误)/.test(text) && !/(?:原因|改正|防再犯|避免|机制|解法|修正)/.test(text)) {
|
|
149
|
+
hits.push({
|
|
150
|
+
ruleId: "22",
|
|
151
|
+
title: errorCfg.title,
|
|
152
|
+
kind: "self-certify",
|
|
153
|
+
reason: "规则 22:被指出错误后需主动给出原因、改正、防再犯解法,不要只道歉"
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 规则 19⑥:版本记录≠完成——只报版本记录未列正文同步时触发自证
|
|
158
|
+
const rule19Cfg = byId.get("19");
|
|
159
|
+
if (rule19Cfg && /版本记录|v\d+\.\d+/.test(text) && /已记入版本记录|版本记录已|只加了版本记录|只加版本记录/.test(text) && !/正文|同步|无需同步|无需改正文/.test(text)) {
|
|
160
|
+
hits.push({
|
|
161
|
+
ruleId: "19",
|
|
162
|
+
title: rule19Cfg.title,
|
|
163
|
+
kind: "self-certify",
|
|
164
|
+
reason: "规则 19⑥:检测到只报版本记录未列正文同步;请列出同步的正文位置,或说明“无需同步正文”及理由"
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// D 级自证泛化:按规则特征触发,每规则每会话上限由 maybeInject 控制
|
|
169
|
+
for (const cfg of configs) {
|
|
170
|
+
if (cfg.confidence === "low") continue;
|
|
171
|
+
if (!(cfg.actions || []).includes("self-certify")) continue;
|
|
172
|
+
const hint = SELF_CERT_HINTS[String(cfg.ruleId)];
|
|
173
|
+
if (!hint) continue;
|
|
174
|
+
if (hint.re.test(text)) {
|
|
175
|
+
hits.push({
|
|
176
|
+
ruleId: String(cfg.ruleId),
|
|
177
|
+
title: cfg.title,
|
|
178
|
+
kind: "self-certify",
|
|
179
|
+
reason: hint.reason
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return hits;
|
|
185
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// understander.js - 规则理解器(模式库兜底版)。
|
|
2
|
+
// 输入 parser 解析出的规则,输出结构化执行配置。
|
|
3
|
+
// LLM 理解器可在后续版本接入 ctx.llm,当前先保证确定性与可测试性。
|
|
4
|
+
import { extractElements } from "./parser.js";
|
|
5
|
+
import {
|
|
6
|
+
BOM_WRITE,
|
|
7
|
+
DESTRUCTIVE_CMD,
|
|
8
|
+
DSH_KEYWORDS_RE,
|
|
9
|
+
INLINE_CMD,
|
|
10
|
+
SENSITIVE_CMD,
|
|
11
|
+
TIME_WORDS,
|
|
12
|
+
PROMISE_WORDS,
|
|
13
|
+
URL_RE,
|
|
14
|
+
SOURCE_MARK
|
|
15
|
+
} from "./patterns.js";
|
|
16
|
+
|
|
17
|
+
const HANDLER_BY_RULE = {
|
|
18
|
+
1: "rule1-retry",
|
|
19
|
+
2: "rule2-time",
|
|
20
|
+
5: "rule5-source",
|
|
21
|
+
7: "rule7-promise",
|
|
22
|
+
9: "rule9-inline-bom",
|
|
23
|
+
11: "rule11-language",
|
|
24
|
+
12: "rule12-approval",
|
|
25
|
+
"12A": "rule12a-approval",
|
|
26
|
+
"12B": "rule12b-skill",
|
|
27
|
+
"12C": "rule12c-network",
|
|
28
|
+
"12D": "rule12d-sensitive",
|
|
29
|
+
13: "rule13-backup",
|
|
30
|
+
"13A": "rule13a-backup",
|
|
31
|
+
"13B": "rule13b-session",
|
|
32
|
+
14: "rule14-report",
|
|
33
|
+
18: "rule18-manual-first",
|
|
34
|
+
21: "rule21-meta",
|
|
35
|
+
22: "rule22-direct",
|
|
36
|
+
23: "rule23-runtime-verify",
|
|
37
|
+
24: "rule24-assembly-type",
|
|
38
|
+
25: "rule25-tool-coverage",
|
|
39
|
+
26: "rule26-release-asset",
|
|
40
|
+
27: "rule27-mount-audit"
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** 根据执行等级推导动作 */
|
|
44
|
+
export function actionsForLevel(level) {
|
|
45
|
+
const s = String(level || "").toUpperCase();
|
|
46
|
+
const actions = [];
|
|
47
|
+
if (s.includes("A")) actions.push("deny");
|
|
48
|
+
if (s.includes("B")) actions.push("correct");
|
|
49
|
+
if (s.includes("C")) actions.push("ask");
|
|
50
|
+
if (s.includes("D")) actions.push("self-certify");
|
|
51
|
+
if (s.includes("M")) actions.push("meta");
|
|
52
|
+
if (actions.length === 0) actions.push("self-certify");
|
|
53
|
+
return [...new Set(actions)];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function splitKeywords(text) {
|
|
57
|
+
if (!text) return [];
|
|
58
|
+
return text
|
|
59
|
+
.split(/[,。;、,\n;::/\\()()]+/)
|
|
60
|
+
.map((s) => s.trim())
|
|
61
|
+
.filter((s) => s.length >= 2 && s.length <= 20);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 从检查文本提取机器可用的提示词 */
|
|
65
|
+
function hintPatterns(checkText) {
|
|
66
|
+
const hints = [];
|
|
67
|
+
if (/node\s+-e|node\s+-p|pwsh\s+-c|--eval|--print|-Command\b/i.test(checkText)) hints.push("inline-command");
|
|
68
|
+
if (/set-content|out-file|add-content|writealltext|utf8/i.test(checkText)) hints.push("bom-write");
|
|
69
|
+
if (/ask_user_question|授权|弹框/i.test(checkText)) hints.push("ask");
|
|
70
|
+
if (/get-date|时间词|昨天|今天/i.test(checkText)) hints.push("time");
|
|
71
|
+
if (/skill|技能/i.test(checkText)) hints.push("skill");
|
|
72
|
+
if (/git\s+(push|commit)|敏感操作|授权/i.test(checkText)) hints.push("sensitive");
|
|
73
|
+
if (/删除|覆盖|备份|验证/i.test(checkText)) hints.push("backup");
|
|
74
|
+
if (/手册/i.test(checkText)) hints.push("manual");
|
|
75
|
+
if (/重试|连续失败|第\s*3\s*次/i.test(checkText)) hints.push("retry");
|
|
76
|
+
if (/运行时验证|mock|启动|实测/i.test(checkText)) hints.push("runtime-verify");
|
|
77
|
+
if (/URL|来源|出处|引用/i.test(checkText)) hints.push("source");
|
|
78
|
+
if (/中文|英文|语言/i.test(checkText)) hints.push("language");
|
|
79
|
+
return [...new Set(hints)];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 理解一条规则。
|
|
84
|
+
* @param {object} rule parser 输出
|
|
85
|
+
* @returns {object} 执行配置
|
|
86
|
+
*/
|
|
87
|
+
export function understandRule(rule) {
|
|
88
|
+
const elems = extractElements(rule.body || "");
|
|
89
|
+
const level = rule.level || "";
|
|
90
|
+
const actions = actionsForLevel(level);
|
|
91
|
+
const hints = hintPatterns(elems.check);
|
|
92
|
+
const triggerKeywords = splitKeywords(elems.trigger);
|
|
93
|
+
const confidence = level && elems.trigger && elems.check && (elems.action || level.toUpperCase().includes("D")) ? "high" : level ? "medium" : "low";
|
|
94
|
+
const handler = HANDLER_BY_RULE[rule.index] || "";
|
|
95
|
+
const config = {
|
|
96
|
+
ruleId: rule.index,
|
|
97
|
+
title: rule.title,
|
|
98
|
+
section: rule.section,
|
|
99
|
+
level,
|
|
100
|
+
actions,
|
|
101
|
+
confidence,
|
|
102
|
+
handler,
|
|
103
|
+
triggerKeywords,
|
|
104
|
+
hints,
|
|
105
|
+
elements: elems,
|
|
106
|
+
disabled: false
|
|
107
|
+
};
|
|
108
|
+
return config;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 批量理解 */
|
|
112
|
+
export function understandAll(rules) {
|
|
113
|
+
return rules.map(understandRule);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 导出常用正则供测试/调试 */
|
|
117
|
+
export const REGEX = {
|
|
118
|
+
INLINE_CMD,
|
|
119
|
+
BOM_WRITE,
|
|
120
|
+
DESTRUCTIVE_CMD,
|
|
121
|
+
SENSITIVE_CMD,
|
|
122
|
+
TIME_WORDS,
|
|
123
|
+
PROMISE_WORDS,
|
|
124
|
+
URL_RE,
|
|
125
|
+
SOURCE_MARK,
|
|
126
|
+
DSH_KEYWORDS_RE
|
|
127
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// understanding-store.js - 理解产物读写。
|
|
2
|
+
// rule-understanding.json 对模型只读(由守卫保护),unlock 后可改。
|
|
3
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { understandingFilePath } from "./paths.js";
|
|
5
|
+
|
|
6
|
+
export function writeUnderstanding(configs) {
|
|
7
|
+
try {
|
|
8
|
+
const payload = {
|
|
9
|
+
generatedAt: new Date().toISOString(),
|
|
10
|
+
engine: "pattern-v1",
|
|
11
|
+
rules: configs
|
|
12
|
+
};
|
|
13
|
+
writeFileSync(understandingFilePath(), JSON.stringify(payload, null, 2), "utf8");
|
|
14
|
+
return { ok: true, path: understandingFilePath() };
|
|
15
|
+
} catch (error) {
|
|
16
|
+
return { ok: false, error: String(error) };
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function readUnderstanding() {
|
|
21
|
+
try {
|
|
22
|
+
const raw = readFileSync(understandingFilePath(), "utf8");
|
|
23
|
+
return { ok: true, data: JSON.parse(raw) };
|
|
24
|
+
} catch (error) {
|
|
25
|
+
return { ok: false, error: String(error) };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// version-guard.js - 手册/版本记录类文件写后自检(P1)
|
|
2
|
+
// 纯函数,可独立测试。
|
|
3
|
+
import { basename } from "node:path";
|
|
4
|
+
|
|
5
|
+
const VERSIONED_NAMES = new Set(["skill.md", "agents.md", "changelog.md", "readme.md"]);
|
|
6
|
+
|
|
7
|
+
export function isVersionedFile(p) {
|
|
8
|
+
if (typeof p !== "string") return false;
|
|
9
|
+
const name = basename(p).toLowerCase();
|
|
10
|
+
if (VERSIONED_NAMES.has(name)) return true;
|
|
11
|
+
return /版本|version|changelog/i.test(p);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** 从文本中提取版本号列表,只匹配版本记录表行(如 | v3.32 |),不扫全文 */
|
|
15
|
+
export function extractVersions(text) {
|
|
16
|
+
if (typeof text !== "string") return [];
|
|
17
|
+
const out = [];
|
|
18
|
+
const lines = text.split("\n");
|
|
19
|
+
const re = /^\s*\| v(\d+)\.(\d+) \|/;
|
|
20
|
+
for (const line of lines) {
|
|
21
|
+
const m = line.match(re);
|
|
22
|
+
if (m) out.push({ major: Number(m[1]), minor: Number(m[2]) });
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 校验版本号连续无重复:同一 major 下 minor 从 min 到 max 无缺失、无重复 */
|
|
28
|
+
export function validateVersionContinuity(text) {
|
|
29
|
+
const versions = extractVersions(text);
|
|
30
|
+
if (versions.length === 0) return { ok: true, errors: [] };
|
|
31
|
+
const byMajor = new Map();
|
|
32
|
+
for (const v of versions) {
|
|
33
|
+
if (!byMajor.has(v.major)) byMajor.set(v.major, new Set());
|
|
34
|
+
byMajor.get(v.major).add(v.minor);
|
|
35
|
+
}
|
|
36
|
+
const errors = [];
|
|
37
|
+
for (const [major, minors] of byMajor) {
|
|
38
|
+
const arr = [...minors].sort((a, b) => a - b);
|
|
39
|
+
const min = arr[0];
|
|
40
|
+
const max = arr[arr.length - 1];
|
|
41
|
+
for (let i = min; i <= max; i++) {
|
|
42
|
+
if (!minors.has(i)) {
|
|
43
|
+
errors.push(`v${major}.${i} 缺失`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (arr.length !== max - min + 1) {
|
|
47
|
+
errors.push(`v${major} 版本号不连续`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return { ok: errors.length === 0, errors };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 判断是否为“版本记录行内仅版本号变更”(如 v3.41 → v3.42,重编号场景) */
|
|
54
|
+
function isVersionRenumber(oldString, newString) {
|
|
55
|
+
if (typeof oldString !== "string" || typeof newString !== "string") return false;
|
|
56
|
+
const re = /^(\s*\| )v\d+\.\d+( \|[\s\S]*)$/;
|
|
57
|
+
const m1 = oldString.match(re);
|
|
58
|
+
const m2 = newString.match(re);
|
|
59
|
+
if (!m1 || !m2) return false;
|
|
60
|
+
return m1[1] === m2[1] && m1[2] === m2[2];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 判断是否单行文本(不含换行) */
|
|
64
|
+
function isSingleLine(s) {
|
|
65
|
+
return !s.includes("\n") && !s.includes("\r");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 判断是否 Markdown 表格行 */
|
|
69
|
+
function isTableRow(s) {
|
|
70
|
+
return /^\s*\|/.test(s);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 提取单行文本的“行锚点”:规则编号 > 标题前缀+首词 > 列表符号+首段标签 > 首词 */
|
|
74
|
+
function lineAnchor(s) {
|
|
75
|
+
const rule = s.match(/\[规则\s+([^\]]+)\]/);
|
|
76
|
+
if (rule) return "rule:" + rule[1].trim();
|
|
77
|
+
const h = s.match(/^(#{1,6})\s+(.+)$/);
|
|
78
|
+
if (h) return "h" + h[1].length + ":" + h[2].trim().split(/\s+/)[0];
|
|
79
|
+
const li = s.match(/^(\s*(?:[-*+]|\d+[.)])\s+)(.*)$/);
|
|
80
|
+
if (li) {
|
|
81
|
+
const token = li[2].trim().split(/[::\s]/)[0];
|
|
82
|
+
return "li:" + token;
|
|
83
|
+
}
|
|
84
|
+
const m = s.match(/^(\S+)/);
|
|
85
|
+
return "txt:" + (m ? m[1] : "");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 非表格单行替换:行锚点一致即放行(同列表项/同标题/同规则编号的正文修正) */
|
|
89
|
+
function isSameLineReplacement(oldString, newString) {
|
|
90
|
+
if (!isSingleLine(oldString) || !isSingleLine(newString)) return false;
|
|
91
|
+
if (isTableRow(oldString) || isTableRow(newString)) return false;
|
|
92
|
+
return lineAnchor(oldString) === lineAnchor(newString);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 校验 append/删除式编辑:新增需包含 old_string;删除/缩短时 new_string 可为 old_string 的子串;版本行重编号放行;非表格单行修改放行 */
|
|
96
|
+
export function validateEditAppend(oldString, newString) {
|
|
97
|
+
if (typeof oldString !== "string" || typeof newString !== "string") return { ok: true, errors: [] };
|
|
98
|
+
if (oldString.length === 0) return { ok: true, errors: [] };
|
|
99
|
+
if (isVersionRenumber(oldString, newString)) return { ok: true, errors: [] };
|
|
100
|
+
if (newString.includes(oldString)) return { ok: true, errors: [] };
|
|
101
|
+
if (oldString.includes(newString)) return { ok: true, errors: [] };
|
|
102
|
+
if (isSameLineReplacement(oldString, newString)) return { ok: true, errors: [] };
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
errors: ["new_string 未包含 old_string,疑似覆盖上一行"]
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 综合校验一次编辑后的文件 */
|
|
110
|
+
export function validateEditedFile(originalText, currentText, oldString, newString) {
|
|
111
|
+
const errors = [];
|
|
112
|
+
const continuity = validateVersionContinuity(currentText);
|
|
113
|
+
if (!continuity.ok) errors.push(...continuity.errors);
|
|
114
|
+
const append = validateEditAppend(oldString, newString);
|
|
115
|
+
if (!append.ok) errors.push(...append.errors);
|
|
116
|
+
return { ok: errors.length === 0, errors };
|
|
117
|
+
}
|