@kira905/dsh-memory-toolkit 0.0.0-stage → 0.1.1
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/CHANGELOG.md +42 -0
- package/LICENSE +661 -0
- package/README.en.md +110 -0
- package/README.md +102 -2
- package/dsh-memory-local/FORK-BASE.json +24 -0
- package/dsh-memory-local/README.md +113 -0
- package/dsh-memory-local/build.mjs +101 -0
- package/dsh-memory-local/cordis.patch.yml +20 -0
- package/dsh-memory-local/deploy.mjs +306 -0
- package/dsh-memory-local/entity-status.mjs +75 -0
- package/dsh-memory-local/lib/bridge.mjs +108 -0
- package/dsh-memory-local/lib/host-adapter.js +1171 -0
- package/dsh-memory-local/lib/index.d.ts +128 -0
- package/dsh-memory-local/lib/index.js +944 -0
- package/dsh-memory-local/lib/injector.mjs +400 -0
- package/dsh-memory-local/lib/memory-read.mjs +116 -0
- package/dsh-memory-local/lib/module-gate.mjs +68 -0
- package/dsh-memory-local/lib/pointer.mjs +152 -0
- package/dsh-memory-local/lib/prod-gate.mjs +121 -0
- package/dsh-memory-local/lib/recall-log.mjs +118 -0
- package/dsh-memory-local/lib/recall.mjs +192 -0
- package/dsh-memory-local/lib/schema-migrate.mjs +365 -0
- package/dsh-memory-local/lib/user-message.mjs +32 -0
- package/dsh-memory-local/lib/vectors-read.mjs +110 -0
- package/dsh-memory-local/package.json +51 -0
- package/dsh-memory-local/restore.mjs +259 -0
- package/dsh-memory-local/rollback-injector-fix.mjs +166 -0
- package/dsh-memory-local/src/bridge.mjs +108 -0
- package/dsh-memory-local/src/host-adapter.js +1171 -0
- package/dsh-memory-local/src/index.d.ts +128 -0
- package/dsh-memory-local/src/index.js +944 -0
- package/dsh-memory-local/src/injector.mjs +400 -0
- package/dsh-memory-local/src/memory-read.mjs +116 -0
- package/dsh-memory-local/src/module-gate.mjs +68 -0
- package/dsh-memory-local/src/pointer.mjs +152 -0
- package/dsh-memory-local/src/recall-log.mjs +118 -0
- package/dsh-memory-local/src/recall.mjs +192 -0
- package/dsh-memory-local/src/schema-migrate.mjs +365 -0
- package/dsh-memory-local/src/user-message.mjs +32 -0
- package/dsh-memory-local/src/vectors-read.mjs +110 -0
- package/dsh-memory-local/test/fixtures-s2b.mjs +216 -0
- package/dsh-memory-local/test/probe-entity.mjs +170 -0
- package/dsh-memory-local/test/regression.mjs +270 -0
- package/dsh-memory-local/test/s2b.test.mjs +568 -0
- package/dsh-memory-local/test/schema-migrate.test.mjs +334 -0
- package/dsh-memory-local/test/test-adapter-converge.mjs +127 -0
- package/dsh-memory-local/test/visible-injections.test.mjs +239 -0
- package/dsh-memory-local/verify-after-restart.mjs +181 -0
- package/lib/deploy-guard.mjs +391 -0
- package/lib/guard-http.mjs +93 -0
- package/lib/pitfall-form-map.mjs +130 -0
- package/lib/pitfall-score.mjs +185 -0
- package/lib/session-file-name.mjs +160 -0
- package/lib/zstd-frames.mjs +78 -0
- package/memory-db-tools/README.md +68 -0
- package/memory-db-tools/_test/test-memory-scope.mjs +302 -0
- package/memory-db-tools/audit-analyze.mjs +352 -0
- package/memory-db-tools/audit-dump-ids.mjs +245 -0
- package/memory-db-tools/audit-export.mjs +270 -0
- package/memory-db-tools/audit-hit-probe.mjs +227 -0
- package/memory-db-tools/audit-ledger-probe.mjs +236 -0
- package/memory-db-tools/audit-persona-overlap.mjs +277 -0
- package/memory-db-tools/audit-struct-probe.mjs +213 -0
- package/memory-db-tools/audit-verify-unchanged.mjs +276 -0
- package/memory-db-tools/check-memory-db.mjs +8 -0
- package/memory-db-tools/dump-memory-db.mjs +12 -0
- package/memory-db-tools/lib/memory-scope.mjs +446 -0
- package/memory-db-tools/list-memory-summary.mjs +477 -0
- package/memory-db-tools/memory-identity-check.mjs +101 -0
- package/memory-db-tools/memory-schema-migrate.mjs +290 -0
- package/memory-db-tools/memory-scope-fixture.mjs +197 -0
- package/memory-db-tools/read-memories.mjs +26 -0
- package/memory-db-tools/read-memory-db.mjs +20 -0
- package/memory-db-tools/refine-memories.mjs +247 -0
- package/memory-db-tools/show-mem-times.mjs +22 -0
- package/memory-engine/README.md +70 -0
- package/memory-engine/bench/bench.mjs +221 -0
- package/memory-engine/cli/distill-rollback.mjs +86 -0
- package/memory-engine/cli/distill.mjs +156 -0
- package/memory-engine/cli/doc-index-rebuild.mjs +155 -0
- package/memory-engine/cli/doc-index.mjs +48 -0
- package/memory-engine/cli/domain-tags.mjs +251 -0
- package/memory-engine/cli/pitfall-hook.mjs +91 -0
- package/memory-engine/cli/recall.mjs +132 -0
- package/memory-engine/cli/selfcheck.mjs +57 -0
- package/memory-engine/cli/serve-start.mjs +247 -0
- package/memory-engine/cli/sweep.mjs +58 -0
- package/memory-engine/cli/sync.mjs +69 -0
- package/memory-engine/lib/cli-util.mjs +197 -0
- package/memory-engine/lib/config.mjs +135 -0
- package/memory-engine/lib/dirty-ledger.mjs +174 -0
- package/memory-engine/lib/distill-rollback.mjs +178 -0
- package/memory-engine/lib/distill.mjs +898 -0
- package/memory-engine/lib/doc-index-store.mjs +325 -0
- package/memory-engine/lib/doc-index.mjs +529 -0
- package/memory-engine/lib/doc-scan.mjs +271 -0
- package/memory-engine/lib/doc-summarize.mjs +305 -0
- package/memory-engine/lib/domain-tags.mjs +429 -0
- package/memory-engine/lib/embedder.mjs +242 -0
- package/memory-engine/lib/hashes.mjs +12 -0
- package/memory-engine/lib/memory-source.mjs +127 -0
- package/memory-engine/lib/pitfall-source.mjs +170 -0
- package/memory-engine/lib/policy-source.mjs +371 -0
- package/memory-engine/lib/recall.mjs +331 -0
- package/memory-engine/lib/selfcheck.mjs +131 -0
- package/memory-engine/lib/sweep.mjs +159 -0
- package/memory-engine/lib/sync.mjs +180 -0
- package/memory-engine/lib/tick-hook.mjs +218 -0
- package/memory-engine/lib/vectors-store.mjs +292 -0
- package/memory-engine/package-lock.json +1086 -0
- package/memory-engine/package.json +22 -0
- package/memory-engine/serve.mjs +171 -0
- package/memory-engine/test/01-store.test.mjs +127 -0
- package/memory-engine/test/02-sync.test.mjs +196 -0
- package/memory-engine/test/03-sweep.test.mjs +145 -0
- package/memory-engine/test/04-recall.test.mjs +214 -0
- package/memory-engine/test/05-selfcheck.test.mjs +175 -0
- package/memory-engine/test/06-doc-index.test.mjs +928 -0
- package/memory-engine/test/07-domain-tags.test.mjs +298 -0
- package/memory-engine/test/08-distill.test.mjs +686 -0
- package/memory-engine/test/09-pitfall-source.test.mjs +365 -0
- package/memory-engine/test/10-tick-hook.test.mjs +252 -0
- package/memory-engine/test/11-policy-source.test.mjs +224 -0
- package/memory-engine/test/_tickhook-worker.mjs +56 -0
- package/memory-engine/test/fixtures.mjs +205 -0
- package/memory-engine/test/harness.mjs +73 -0
- package/memory-engine/test/run.mjs +35 -0
- package/memory-engine/tools/bridge-smoke.mjs +80 -0
- package/memory-engine/tools/fetch-model.mjs +128 -0
- package/memory-engine/tools/smoke-recall.mjs +76 -0
- package/package.json +53 -4
- package/persona-memory-audit.mjs +217 -0
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
// tools\lib\deploy-guard.mjs —— 部署门禁共享件(参数白名单 / 逃逸 import 静态扫 / 部署位置冒烟 import)
|
|
2
|
+
//
|
|
3
|
+
// ❗来由(2026-09-16 23:21 DSH 崩溃事故,2026-09-17 推广):某 deploy.mjs 收到不认识的 `--verify`
|
|
4
|
+
// 被**静默忽略** → 以为在做只读检查,实际执行了真备份 + 真部署 + 内部 spawnSync dsh-restart.ps1
|
|
5
|
+
// 硬重启(绕开 P0 闸门);而部署进 profile 的模块用相对路径跨包 import(仓库里层级恰好对上、
|
|
6
|
+
// 装进 profiles\web\node_modules 后指向不存在的包)→ dsh boot 期 ERR_MODULE_NOT_FOUND,
|
|
7
|
+
// 120s 内连崩 3 次唤分身。教训:`node --check` 只查**语法**,查不出**解析期**失败。
|
|
8
|
+
// 首版落地在 tools\dsh-butler-panel\{preflight-imports.mjs,deploy.mjs}(拷问修复·B 卡),本文件是
|
|
9
|
+
// 同口径的共享实现,供其余 deploy.mjs 复用。
|
|
10
|
+
//
|
|
11
|
+
// 三个导出(纯函数 + 薄 IO,零外部依赖):
|
|
12
|
+
// parseArgs(argv, known) fail-closed 参数白名单:未知参数 → ok:false(调用方 rc=2 退出)
|
|
13
|
+
// findEscapingImports(srcDir, opts) 静态扫「相对 import 解析后逃出插件根」的写法 → 命中清单
|
|
14
|
+
// smokeImportAll(entityDir, libSpec) 以**部署位置**为解析上下文,逐模块冒烟 import(子进程隔离)
|
|
15
|
+
//
|
|
16
|
+
// 退出码口径(与 tools\dsh-butler-panel\deploy.mjs 对齐):
|
|
17
|
+
// 0 = 通过 · 1 = 门禁未过(拒绝部署)· 2 = 用法/参数错误
|
|
18
|
+
//
|
|
19
|
+
// 用法示例见各 deploy.mjs 接入段;本文件自身不写任何文件(除子进程无副作用)。
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { spawnSync } from 'node:child_process';
|
|
23
|
+
import { pathToFileURL } from 'node:url';
|
|
24
|
+
|
|
25
|
+
export const EXIT = { OK: 0, GATE: 1, USAGE: 2 };
|
|
26
|
+
|
|
27
|
+
// 递归扫描时一律跳过的目录(node_modules 是依赖闭包、_archive/_tmp 是历史与临时产物;
|
|
28
|
+
// task-board-local 的 node_modules 还是 junction → 扫进去会顺着盘外闭包跑几十个包)。
|
|
29
|
+
const DEFAULT_EXCLUDE_DIRS = new Set([
|
|
30
|
+
'node_modules', '.git', '.hg', '.svn', '_archive', '_tmp', 'dist', 'build',
|
|
31
|
+
'coverage', '.cache', '.dsh', '.venv', '__pycache__', 'snapshots', 'backups',
|
|
32
|
+
]);
|
|
33
|
+
const JS_EXT = new Set(['.mjs', '.js', '.cjs']);
|
|
34
|
+
// 前端产物:浏览器 bundle 本来就不该在 node 里 import(真 import 必报 window is not defined)。
|
|
35
|
+
const DEFAULT_SMOKE_SKIP = [/^client\.js$/, /\.browser\.js$/, /\.min\.js$/];
|
|
36
|
+
|
|
37
|
+
// ───────────────────────────────────────────────────────────── 1) 参数白名单
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* fail-closed 解析命令行参数。
|
|
41
|
+
* @param {string[]} argv process.argv.slice(2)
|
|
42
|
+
* @param {Array<string|{flag:string, takesValue?:boolean, alias?:string}>} known 白名单
|
|
43
|
+
* 字符串 = 布尔开关;对象 = 可取值的参数(--src X 或 --src=X)
|
|
44
|
+
* @returns {{ok:boolean, unknown:string[], flags:Record<string,boolean>,
|
|
45
|
+
* values:Record<string,string>, positionals:string[]}}
|
|
46
|
+
* ok=false 时 unknown 为全部不认识的 token(调用方负责打印 + 以 rc=2 退出)
|
|
47
|
+
*/
|
|
48
|
+
export function parseArgs(argv, known = []) {
|
|
49
|
+
const spec = new Map();
|
|
50
|
+
for (const k of known) {
|
|
51
|
+
if (typeof k === 'string') spec.set(k, { takesValue: false });
|
|
52
|
+
else if (k && k.flag) {
|
|
53
|
+
spec.set(k.flag, { takesValue: !!k.takesValue });
|
|
54
|
+
if (k.alias) spec.set(k.alias, { takesValue: !!k.takesValue, aliasOf: k.flag });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const flags = {};
|
|
58
|
+
const values = {};
|
|
59
|
+
const unknown = [];
|
|
60
|
+
const positionals = [];
|
|
61
|
+
for (let i = 0; i < argv.length; i++) {
|
|
62
|
+
const tok = argv[i];
|
|
63
|
+
if (!tok.startsWith('-')) { positionals.push(tok); continue; }
|
|
64
|
+
const eq = tok.indexOf('=');
|
|
65
|
+
const name = eq > 0 ? tok.slice(0, eq) : tok;
|
|
66
|
+
const s = spec.get(name);
|
|
67
|
+
if (!s) { unknown.push(tok); continue; }
|
|
68
|
+
const key = s.aliasOf ?? name;
|
|
69
|
+
if (s.takesValue) {
|
|
70
|
+
let v = eq > 0 ? tok.slice(eq + 1) : null;
|
|
71
|
+
if (v === null) { v = argv[i + 1] ?? null; i++; }
|
|
72
|
+
values[key] = v;
|
|
73
|
+
flags[key] = true;
|
|
74
|
+
} else if (eq > 0) {
|
|
75
|
+
unknown.push(tok); // 布尔开关不许带 =值(写错了要响,不要静默)
|
|
76
|
+
} else {
|
|
77
|
+
flags[key] = true;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// 位置参数一律拒绝:这些 deploy 脚本都不吃位置参数,静默吞掉正是事故形态。
|
|
81
|
+
unknown.push(...positionals);
|
|
82
|
+
return { ok: unknown.length === 0, unknown, flags, values, positionals };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 把 unknown 渲染成人话(各脚本拼自己的「只读模式请用 X」尾巴)。 */
|
|
86
|
+
export function describeUnknown(unknown, known) {
|
|
87
|
+
const all = known.map((k) => (typeof k === 'string' ? k : k.flag)).join(' ');
|
|
88
|
+
return `不认识的参数:${unknown.join(' ')}(本脚本只认 ${all})`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ───────────────────────────────────────────────────── 2) 逃逸 import 静态扫
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 归一化路径:**存在的部分走 realpath,不存在的尾巴接回去**。
|
|
95
|
+
* ⚠️ 必须两边都归一 —— 工作区里的目录 junction 很常见:只 realpath 一边会把
|
|
96
|
+
* 「逻辑路径 vs 物理路径」比成 ..\..\...,于是合法的 './state.js' 也被误判成逃逸(实测踩过)。
|
|
97
|
+
*/
|
|
98
|
+
export function normalizePath(p) {
|
|
99
|
+
let cur = path.resolve(p);
|
|
100
|
+
const tail = [];
|
|
101
|
+
for (;;) {
|
|
102
|
+
try {
|
|
103
|
+
const real = fs.realpathSync.native ? fs.realpathSync.native(cur) : fs.realpathSync(cur);
|
|
104
|
+
return tail.length ? path.join(real, ...tail.reverse()) : real;
|
|
105
|
+
} catch {
|
|
106
|
+
const parent = path.dirname(cur);
|
|
107
|
+
if (parent === cur) return path.resolve(p);
|
|
108
|
+
tail.push(path.basename(cur));
|
|
109
|
+
cur = parent;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 去注释:注释内容替换成空格(保留换行与列位置),引号内原样保留。
|
|
116
|
+
* 目的 = 避免把注释里写的路径当成真 import(事故文件头注里就写着示例路径)。
|
|
117
|
+
* 简易状态机:支持 // 、/* *\/ 、'…'、"…"、`…`(模板串内的 ${} 不再细分,漏掉的由门禁②兜底)。
|
|
118
|
+
*/
|
|
119
|
+
export function stripComments(text) {
|
|
120
|
+
const out = [];
|
|
121
|
+
let i = 0;
|
|
122
|
+
let inBlock = false;
|
|
123
|
+
let quote = null;
|
|
124
|
+
while (i < text.length) {
|
|
125
|
+
const c = text[i];
|
|
126
|
+
const n = text[i + 1];
|
|
127
|
+
if (inBlock) {
|
|
128
|
+
if (c === '*' && n === '/') { out.push(' '); i += 2; inBlock = false; continue; }
|
|
129
|
+
out.push(c === '\n' ? '\n' : ' ');
|
|
130
|
+
i++;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (quote) {
|
|
134
|
+
if (c === '\\') { out.push(c, n === undefined ? '' : n); i += 2; continue; }
|
|
135
|
+
out.push(c);
|
|
136
|
+
i++;
|
|
137
|
+
if (c === quote) quote = null;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (c === '/' && n === '*') { out.push(' '); i += 2; inBlock = true; continue; }
|
|
141
|
+
if (c === '/' && n === '/') {
|
|
142
|
+
while (i < text.length && text[i] !== '\n') { out.push(' '); i++; }
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (c === '"' || c === "'" || c === '`') { quote = c; out.push(c); i++; continue; }
|
|
146
|
+
out.push(c);
|
|
147
|
+
i++;
|
|
148
|
+
}
|
|
149
|
+
return out.join('');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 只认「import/export … from '…'」「import '…'」「import('…')」「require('…')」四种形态。
|
|
153
|
+
const SPEC_RES = [
|
|
154
|
+
/(?:^|[^\w$.])(?:import|export)\b[^;]*?\bfrom\s*(['"])([^'"]+)\1/,
|
|
155
|
+
/(?:^|[^\w$.])import\s*(['"])([^'"]+)\1/,
|
|
156
|
+
/(?:^|[^\w$.])import\s*\(\s*(['"])([^'"]+)\1\s*\)/,
|
|
157
|
+
/(?:^|[^\w$.])require\s*\(\s*(['"])([^'"]+)\1\s*\)/,
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
/** 列出目录下全部 js/mjs/cjs(递归、排序、跳过黑名单目录)。 */
|
|
161
|
+
export function listJsFiles(root, opts = {}) {
|
|
162
|
+
const { excludeDirs = DEFAULT_EXCLUDE_DIRS, exts = JS_EXT } = opts;
|
|
163
|
+
const out = [];
|
|
164
|
+
const walk = (d) => {
|
|
165
|
+
let ents = [];
|
|
166
|
+
try { ents = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
|
|
167
|
+
for (const e of ents) {
|
|
168
|
+
if (e.isSymbolicLink()) continue; // junction/链接不跟随(跨盘闭包、盘外目录)
|
|
169
|
+
const full = path.join(d, e.name);
|
|
170
|
+
if (e.isDirectory()) {
|
|
171
|
+
if (!excludeDirs.has(e.name)) walk(full);
|
|
172
|
+
} else if (exts.has(path.extname(e.name))) {
|
|
173
|
+
out.push(full);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
walk(root);
|
|
178
|
+
return out.sort();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 静态扫:srcDir 下 js/mjs/cjs 里「相对 import 解析后逃出 root」的写法。
|
|
183
|
+
* @param {string} srcDir 扫描起点 —— **只扫「会被复制进实体的那部分」**(各插件都是 lib/)。
|
|
184
|
+
* 传插件根会把工具脚本一起扫进来(deploy.mjs / restore.mjs 自己 import '../lib/dsh-home.mjs'
|
|
185
|
+
* 是合法姿势、且它们根本不进 profile)→ 自报假警,实测踩过。
|
|
186
|
+
* @param {{root?:string}} [opts] root = 逃逸判定的根 = **实体的根**(插件被装进 profile 后的那个目录)。
|
|
187
|
+
* 原地部署、不复制进 profile 的插件(如 tools\dsh-butler:整棵 tools 树是它的搬迁单位、
|
|
188
|
+
* 跨子目录引用是合法设计)可放宽到树根。两边都走 realpath 归一(junction 坑)。
|
|
189
|
+
* 跨目录引用的正解是 `file:///<绝对路径>` URL(绝对路径不计逃逸)。
|
|
190
|
+
* @returns {{root:string, scanned:number, specs:number, absolute:number,
|
|
191
|
+
* bad:Array<{file:string,line:number,spec:string,resolved:string,text:string}>}}
|
|
192
|
+
*/
|
|
193
|
+
export function findEscapingImports(srcDir, opts = {}) {
|
|
194
|
+
const root = opts.root ?? srcDir;
|
|
195
|
+
const rootReal = normalizePath(root);
|
|
196
|
+
const files = listJsFiles(srcDir, opts);
|
|
197
|
+
const bad = [];
|
|
198
|
+
let specs = 0;
|
|
199
|
+
let absolute = 0;
|
|
200
|
+
for (const file of files) {
|
|
201
|
+
let raw = '';
|
|
202
|
+
try { raw = fs.readFileSync(file, 'utf8'); } catch { continue; }
|
|
203
|
+
const lines = stripComments(raw).split(/\r?\n/);
|
|
204
|
+
for (let i = 0; i < lines.length; i++) {
|
|
205
|
+
const line = lines[i];
|
|
206
|
+
if (!line.includes('import') && !line.includes('require')) continue;
|
|
207
|
+
const seen = new Set();
|
|
208
|
+
for (const re of SPEC_RES) {
|
|
209
|
+
const m = re.exec(line);
|
|
210
|
+
if (!m || !m[2] || seen.has(m[2])) continue;
|
|
211
|
+
seen.add(m[2]);
|
|
212
|
+
const spec = m[2];
|
|
213
|
+
if (path.isAbsolute(spec)) { absolute++; continue; } // 绝对路径是 B 卡推荐的跨目录姿势,不算逃逸
|
|
214
|
+
if (!spec.startsWith('.')) continue; // 裸包名(走 node_modules 解析)
|
|
215
|
+
specs++;
|
|
216
|
+
const resolved = path.resolve(path.dirname(file), spec);
|
|
217
|
+
const rel = path.relative(rootReal, normalizePath(resolved));
|
|
218
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
219
|
+
bad.push({
|
|
220
|
+
file: path.relative(srcDir, file),
|
|
221
|
+
line: i + 1,
|
|
222
|
+
spec,
|
|
223
|
+
resolved,
|
|
224
|
+
text: raw.split(/\r?\n/)[i].trim().slice(0, 200),
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return { root, scanned: files.length, specs, absolute, bad };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ─────────────────────────────────────────── 3) 部署位置冒烟 import(子进程)
|
|
234
|
+
|
|
235
|
+
// 子进程内跑的两段代码(代码走 stdin,路径走 env —— 无引号/转义地狱)。
|
|
236
|
+
// 快路径 · 批量:一个进程 import 全部模块,结果用 `DG_RESULT:` 前缀单行输出
|
|
237
|
+
// (用前缀而非裸 JSON,是为了容忍被 import 的模块自己往 stdout 打日志)。
|
|
238
|
+
const BATCH_CODE = [
|
|
239
|
+
"const files = JSON.parse(process.env.DG_BATCH_FILES || '[]');",
|
|
240
|
+
"const out = [];",
|
|
241
|
+
"for (const item of files) {",
|
|
242
|
+
" try { await import(item.url); out.push({ file: item.file, ok: true }); }",
|
|
243
|
+
" catch (e) { out.push({ file: item.file, ok: false, err: String((e && (e.message || e)) || e) }); }",
|
|
244
|
+
"}",
|
|
245
|
+
"process.stdout.write('\\nDG_RESULT:' + JSON.stringify(out) + '\\n');",
|
|
246
|
+
"if (process.env.DG_EXIT_AFTER === '1') process.exit(0);",
|
|
247
|
+
"",
|
|
248
|
+
].join('\n');
|
|
249
|
+
|
|
250
|
+
// 慢路径 · 单模块:批量判失败(或进程崩/超时)时逐个定位,并区分「解析失败」与「挂住」。
|
|
251
|
+
const SMOKE_CODE = [
|
|
252
|
+
"const url = process.env.DG_TARGET_URL;",
|
|
253
|
+
"try {",
|
|
254
|
+
" await import(url);",
|
|
255
|
+
"} catch (e) {",
|
|
256
|
+
" process.stderr.write('DG_IMPORT_FAIL: ' + String((e && (e.message || e)) || e));",
|
|
257
|
+
" process.exitCode = 1;",
|
|
258
|
+
"}",
|
|
259
|
+
"// 模块 import 成功但挂着事件循环(定时器/监听)时,DG_EXIT_AFTER=1 让子进程主动收尾,",
|
|
260
|
+
"// 以便把「解析失败」与「解析成功但进程不退出」分开判(后者不该判 FAIL)。",
|
|
261
|
+
"if (!process.exitCode && process.env.DG_EXIT_AFTER === '1') process.exit(0);",
|
|
262
|
+
"",
|
|
263
|
+
].join('\n');
|
|
264
|
+
|
|
265
|
+
function spawnSmoke(file, timeoutMs, exitAfter) {
|
|
266
|
+
return spawnSync(process.execPath, ['--input-type=module'], {
|
|
267
|
+
input: SMOKE_CODE,
|
|
268
|
+
encoding: 'utf8',
|
|
269
|
+
timeout: timeoutMs,
|
|
270
|
+
windowsHide: true,
|
|
271
|
+
env: {
|
|
272
|
+
...process.env,
|
|
273
|
+
DG_TARGET_URL: pathToFileURL(file).href,
|
|
274
|
+
DG_EXIT_AFTER: exitAfter ? '1' : '0',
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function runBatchFast(items, timeoutMs) {
|
|
280
|
+
return spawnSync(process.execPath, ['--input-type=module'], {
|
|
281
|
+
input: BATCH_CODE,
|
|
282
|
+
encoding: 'utf8',
|
|
283
|
+
timeout: timeoutMs,
|
|
284
|
+
windowsHide: true,
|
|
285
|
+
env: {
|
|
286
|
+
...process.env,
|
|
287
|
+
DG_BATCH_FILES: JSON.stringify(items),
|
|
288
|
+
DG_EXIT_AFTER: '1', // 批量模式只回答「能否全部解析」,import 完立刻收尾(免被定时器拖住)
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** 从(可能被模块日志污染的)stdout 里取最后一条 DG_RESULT 行。 */
|
|
294
|
+
function extractBatchResult(stdout) {
|
|
295
|
+
const marker = 'DG_RESULT:';
|
|
296
|
+
const i = (stdout ?? '').lastIndexOf(marker);
|
|
297
|
+
if (i < 0) return null;
|
|
298
|
+
const line = stdout.slice(i + marker.length).split(/\r?\n/)[0];
|
|
299
|
+
try {
|
|
300
|
+
const arr = JSON.parse(line);
|
|
301
|
+
return Array.isArray(arr) ? arr : null;
|
|
302
|
+
} catch {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* 以**部署位置**为解析上下文,逐个 import lib 里的模块。
|
|
309
|
+
* 关键点:import 的是**实体目录里的那个文件**(file URL),模块内部的相对 import 才会按实体
|
|
310
|
+
* 位置解析 —— 这正是「仓库里跑得通、装进 profile 就崩」的暴露面。子进程隔离:模块若有
|
|
311
|
+
* 副作用/挂住事件循环,不会污染部署进程。
|
|
312
|
+
* @param {string} entityDir 部署实体目录(必须是真实存在的路径;原地部署的插件传源码目录自身)
|
|
313
|
+
* @param {string|{dir?:string, skip?:RegExp[], timeoutMs?:number}} [libSpec='lib']
|
|
314
|
+
* @param {{dir?:string, skip?:RegExp[], timeoutMs?:number, only?:RegExp}} [opts]
|
|
315
|
+
* @returns {{dir:string, error:string|null, total:number, skipped:number,
|
|
316
|
+
* bad:Array<{file:string,err:string,timeout:boolean}>, results:Array<{file:string,ok:boolean,ms:number}>}}
|
|
317
|
+
*/
|
|
318
|
+
export function smokeImportAll(entityDir, libSpec = 'lib', opts = {}) {
|
|
319
|
+
const cfg = typeof libSpec === 'string' ? { ...opts, dir: libSpec } : { ...opts, ...libSpec };
|
|
320
|
+
const {
|
|
321
|
+
skip = DEFAULT_SMOKE_SKIP, timeoutMs = 20000, batchTimeoutMs = 90000,
|
|
322
|
+
batch = true, only = null,
|
|
323
|
+
} = cfg;
|
|
324
|
+
const ent = path.resolve(entityDir);
|
|
325
|
+
const libDir = path.isAbsolute(cfg.dir ?? 'lib') ? cfg.dir : path.join(ent, cfg.dir ?? 'lib');
|
|
326
|
+
const empty = { dir: libDir, error: null, mode: null, total: 0, skipped: 0, bad: [], results: [] };
|
|
327
|
+
|
|
328
|
+
if (!fs.existsSync(ent)) return { ...empty, error: '实体目录不存在:' + ent };
|
|
329
|
+
if (!fs.existsSync(libDir)) return { ...empty, error: '实体下没有 lib 目录:' + libDir };
|
|
330
|
+
// 解析上下文必须是实体目录里的文件:lib 不在实体之下 → 拒绝(防"扫了源码目录还以为在测实体")
|
|
331
|
+
const relLib = path.relative(normalizePath(ent), normalizePath(libDir));
|
|
332
|
+
if (relLib.startsWith('..') || path.isAbsolute(relLib)) {
|
|
333
|
+
return { ...empty, error: 'lib 目录不在实体目录内(解析上下文错位):' + libDir };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const files = listJsFiles(libDir).filter((f) => !only || only.test(path.relative(libDir, f)));
|
|
337
|
+
const targets = files.filter((f) => !skip.some((re) => re.test(path.relative(libDir, f))));
|
|
338
|
+
const skipped = files.length - targets.length;
|
|
339
|
+
const bad = [];
|
|
340
|
+
const results = [];
|
|
341
|
+
|
|
342
|
+
// 快路径:单进程批量 import 全部模块(dsh-butler 82 个模块从 ~18s 降到 ~2s)。
|
|
343
|
+
// 判据严格:**只有「批量说全部 ok」才算通过**,否则一律落慢路径逐个确认 —— 批量的假失败
|
|
344
|
+
// (模块间串扰)由慢路径澄清,批量的假成功不存在(每个模块都真 import 过)。
|
|
345
|
+
if (batch && targets.length > 1) {
|
|
346
|
+
const r = runBatchFast(
|
|
347
|
+
targets.map((f) => ({ file: path.relative(libDir, f), url: pathToFileURL(f).href })),
|
|
348
|
+
batchTimeoutMs,
|
|
349
|
+
);
|
|
350
|
+
const parsed = extractBatchResult(r.stdout ?? '');
|
|
351
|
+
if (parsed && parsed.length === targets.length && parsed.every((x) => x.ok)) {
|
|
352
|
+
return {
|
|
353
|
+
dir: libDir, error: null, mode: 'batch', total: targets.length, skipped, bad: [],
|
|
354
|
+
results: parsed.map((x) => ({ file: x.file, ok: true, ms: 0, hung: null })),
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// 慢路径:逐模块(精确定位到文件 + 区分「解析失败」与「import 成功但挂着不退出」)
|
|
360
|
+
for (const f of targets) {
|
|
361
|
+
const rel = path.relative(libDir, f);
|
|
362
|
+
const t0 = Date.now();
|
|
363
|
+
let r = spawnSmoke(f, timeoutMs, false);
|
|
364
|
+
let timeout = !!(r.error || r.signal);
|
|
365
|
+
let hung = false;
|
|
366
|
+
if (timeout) {
|
|
367
|
+
// 第一次超时可能是「模块挂住事件循环」而非解析失败 → 用 import 完主动收尾再试一次区分。
|
|
368
|
+
const r2 = spawnSmoke(f, timeoutMs, true);
|
|
369
|
+
if (r2.status === 0) { r = r2; timeout = false; hung = true; }
|
|
370
|
+
else if (!(r2.error || r2.signal)) { r = r2; timeout = false; }
|
|
371
|
+
}
|
|
372
|
+
const ms = Date.now() - t0;
|
|
373
|
+
if (!timeout && r.status === 0) {
|
|
374
|
+
results.push({ file: rel, ok: true, ms, hung });
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
const err = timeout
|
|
378
|
+
? `${r.error?.code ?? r.signal ?? '超时'}:${timeoutMs}ms 内未结束(import 后进程既不退出也不报错)`
|
|
379
|
+
: extractSmokeError(r.stderr ?? '') || `exit ${r.status}`;
|
|
380
|
+
bad.push({ file: rel, err, timeout });
|
|
381
|
+
results.push({ file: rel, ok: false, ms, hung: false });
|
|
382
|
+
}
|
|
383
|
+
return { dir: libDir, error: null, mode: 'per-module', total: targets.length, skipped, bad, results };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function extractSmokeError(stderr) {
|
|
387
|
+
const marker = stderr.indexOf('DG_IMPORT_FAIL: ');
|
|
388
|
+
let msg = marker >= 0 ? stderr.slice(marker + 'DG_IMPORT_FAIL: '.length) : stderr;
|
|
389
|
+
msg = msg.split(/\r?\n/).filter((l) => l.trim())[0] ?? '';
|
|
390
|
+
return msg.trim().slice(0, 300);
|
|
391
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// guard-http.mjs · 守护鱼/分身「HTTP 存活判据」单一真源(H1 · 2026-09-22)
|
|
3
|
+
// ------------------------------------------------------------
|
|
4
|
+
// ⚠️ 本模块**纯函数、零依赖、零 IO**(不 import 任何东西、不碰网络/进程/文件):
|
|
5
|
+
// 传输层(curl 调用形态)仍留在各消费者里(guard.mjs / repair.mjs),
|
|
6
|
+
// 本模块只回答一个问题 —— **"这个 HTTP 响应算不算'服务活着'"**。
|
|
7
|
+
//
|
|
8
|
+
// 为什么需要它(2026-09-17 升级硬前置 · 实测钉死):
|
|
9
|
+
// 0.1.5-rc.2 起 dsh web 加了 cookie token 认证门:无 token 访问
|
|
10
|
+
// `http://127.0.0.1:<port>/` → **401**(带 token → 303 落 cookie)。
|
|
11
|
+
// 旧判据 = 「必须 HTTP 200」(`(r.stdout||'').trim() === '200'`)⇒ 401 被判
|
|
12
|
+
// "服务没起来" ⇒ KILL → 拉起 → 再杀 的死循环:主包一升 0.1.5,守护鱼就会
|
|
13
|
+
// 把生产 dsh 打进 KILL/CRASH。
|
|
14
|
+
//
|
|
15
|
+
// 口径(H1):「**有 HTTP 应答即活**」,同时**保持 0.1.1-rc.2 线行为等价**
|
|
16
|
+
// (200 仍判活、真死仍判死)。逐码取择与理由:
|
|
17
|
+
// ┌ 状态码 判 理由
|
|
18
|
+
// │ 2xx (200/204) 活 原有唯一判活值,必须保持不变(0.1.1 线 200)
|
|
19
|
+
// │ 3xx (301/303) 活 0.1.5 带 token 访问 `/` = 303(重定向落 cookie);
|
|
20
|
+
// │ 重定向 = 应用在正常处理请求,不是"没起来"
|
|
21
|
+
// │ 401 活 **本次改造核心**:token 认证门 —— 服务在监听,
|
|
22
|
+
// │ 并主动拒绝未认证请求(响应体 "dsh web authentication required")
|
|
23
|
+
// │ 403 活 同属"授权类拒绝":HTTP 服务确实在应答,只是不给内容;
|
|
24
|
+
// │ 与 401 同类(认得出的是"HTTP 服务在",不是"内容对不对")
|
|
25
|
+
// │ 404 等其它 4xx 死 0.1.1 与 0.1.5 的 `/` 都不会 404 ⇒ 出现 404 说明 3080 上的
|
|
26
|
+
// │ 监听者不是 dsh 本体(如端口被别的进程占了)⇒ 正需要"卡死→重启";
|
|
27
|
+
// │ 取择 = 与旧口径一致(宁可误杀重拉,也不把"不是 dsh"当健康)
|
|
28
|
+
// │ 5xx 死 服务进程在但应用层报错/未就绪:保留旧"再探 → 必要时重启"语义,
|
|
29
|
+
// │ 并避免 START 阶段在 bundle 未就绪时过早判 OK
|
|
30
|
+
// │ 无应答/超时 死 与旧口径等价(curl 无输出/000/失败)
|
|
31
|
+
// │ 解析不了(abc) 死 fail-closed:宁可多探一轮,不把未知当健康
|
|
32
|
+
// └
|
|
33
|
+
//
|
|
34
|
+
// 消费者(H1 收敛后的全部读点,改口径只需改本文件):
|
|
35
|
+
// ① 守护进程 guard.mjs probeHttp() → waitHttpReady(START 路径) + 主循环 httpOk
|
|
36
|
+
// (→ guard-core.mjs 的 STUCK?/kill-dsh 判据)
|
|
37
|
+
// ② 自愈分身 repair.mjs probeHttp() → 现场诊断 ev.dsh.httpOk + relaunch 后就绪等待
|
|
38
|
+
// ============================================================
|
|
39
|
+
|
|
40
|
+
/** 口径版本(写进日志与报告,便于事后对照哪一版判据在跑) */
|
|
41
|
+
export const RULE_VERSION = 'h1-20260922';
|
|
42
|
+
|
|
43
|
+
/** 判活的状态码集合(唯一口径,别在消费者里另写判断) */
|
|
44
|
+
export const ALIVE_CODES = [200, 204, 301, 302, 303, 307, 308, 401, 403];
|
|
45
|
+
|
|
46
|
+
/** 判活规则文字说明(报告 / 日志 / 帮助文本共用) */
|
|
47
|
+
export const ALIVE_RULE_TEXT = '2xx/3xx/401/403 = 活;404 等其它 4xx、5xx、无应答 = 不活';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 把 curl `-w '%{http_code}'` 的原始输出解析成状态码。
|
|
51
|
+
* 取择:**严格三位数字**才算解析成功(curl 无应答时输出 `000`,非数字输出如 `abc`/空串一律 null)。
|
|
52
|
+
* @param {unknown} raw
|
|
53
|
+
* @returns {number|null} 0=无应答(000)… 599;null=解析不了
|
|
54
|
+
*/
|
|
55
|
+
export function parseHttpCode(raw) {
|
|
56
|
+
const s = String(raw ?? '').trim();
|
|
57
|
+
if (!/^\d{3}$/.test(s)) return null;
|
|
58
|
+
return Number(s);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 判活 + 给理由(消费者的唯一入口)。
|
|
63
|
+
* @param {unknown} raw curl 输出的状态码原串
|
|
64
|
+
* @returns {{ code: number|null, alive: boolean, reason: string, rule: string }}
|
|
65
|
+
* reason ∈ ok-2xx | redirect-3xx | auth-401 | auth-403 | not-found-404 |
|
|
66
|
+
* client-4xx | server-5xx | no-answer | unparsable
|
|
67
|
+
*/
|
|
68
|
+
export function judgeHttp(raw) {
|
|
69
|
+
const code = parseHttpCode(raw);
|
|
70
|
+
if (code === null) return { code: null, alive: false, reason: 'unparsable', rule: RULE_VERSION };
|
|
71
|
+
if (code === 0) return { code: 0, alive: false, reason: 'no-answer', rule: RULE_VERSION };
|
|
72
|
+
if (code >= 200 && code <= 299) return { code, alive: true, reason: 'ok-2xx', rule: RULE_VERSION };
|
|
73
|
+
if (code >= 300 && code <= 399) return { code, alive: true, reason: 'redirect-3xx', rule: RULE_VERSION };
|
|
74
|
+
if (code === 401) return { code, alive: true, reason: 'auth-401', rule: RULE_VERSION };
|
|
75
|
+
if (code === 403) return { code, alive: true, reason: 'auth-403', rule: RULE_VERSION };
|
|
76
|
+
if (code === 404) return { code, alive: false, reason: 'not-found-404', rule: RULE_VERSION };
|
|
77
|
+
if (code >= 400 && code <= 499) return { code, alive: false, reason: 'client-4xx', rule: RULE_VERSION };
|
|
78
|
+
if (code >= 500 && code <= 599) return { code, alive: false, reason: 'server-5xx', rule: RULE_VERSION };
|
|
79
|
+
return { code, alive: false, reason: 'unparsable', rule: RULE_VERSION };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 只要布尔(沿用旧 `probeHttp` 的返回形状,便于调用方最小改动) */
|
|
83
|
+
export function isHttpAlive(raw) {
|
|
84
|
+
return judgeHttp(raw).alive;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 日志用一行描述,例如 `code=401 (auth-401)` / `no-answer` */
|
|
88
|
+
export function describeHttp(raw) {
|
|
89
|
+
const j = judgeHttp(raw);
|
|
90
|
+
if (j.code === null) return `${j.reason}`;
|
|
91
|
+
if (j.code === 0) return 'no-answer (code=000)';
|
|
92
|
+
return `code=${j.code} (${j.reason})`;
|
|
93
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// dsh-butler / lib/pitfall-form-map.mjs —— P4「错误码 / 签名 → 建议形态」映射表
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要这张表(2026-09-12 主人点名):
|
|
4
|
+
// `pitfall-score.mjs` 的 suggestForm 原先只按文本特征猜形态,实测跑偏——
|
|
5
|
+
// `AUDIT_NOT_PASSED`(真实成因:写保护区没先走 P5 审计)被猜成「路径存在性预检」check,
|
|
6
|
+
// 方向完全不对。**已知错误码的坑,成因是确定的**,不应交给启发式猜。
|
|
7
|
+
//
|
|
8
|
+
// 口径(判定顺序,命中即返回,不再往下走):
|
|
9
|
+
// ① 错误码精确命中本条表(code 字段 > sigKey 的第 2 段 > normText 中出现的 SCREAMING_SNAKE 码)
|
|
10
|
+
// ② 签名正则命中(sigRe;用于没有稳定错误码、只能靠签名特征认的坑)
|
|
11
|
+
// ③ 都没命中 → 回落 `pitfall-score.mjs` 的原启发式(行为与改动前完全一致)
|
|
12
|
+
//
|
|
13
|
+
// 形态只有三种(与方案 §4 P4 一致):`check` 自动检查项 / `skill` 内联技能 / `persona` persona 铁律。
|
|
14
|
+
// 本文件是**纯数据 + 纯函数**,不碰状态、不写盘。
|
|
15
|
+
//
|
|
16
|
+
// 维护:新增一行即可生效(无副作用、无需改 render / 面板);改完跑 `_test/test-pitfall.mjs`。
|
|
17
|
+
|
|
18
|
+
export const FORM_MAP_VERSION = '0.1.0';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {object} FormRule
|
|
22
|
+
* @property {string} id 规则 id(进 why/日志,稳定可断言)
|
|
23
|
+
* @property {string[]} [codes] 命中的错误码(大写 SCREAMING_SNAKE)
|
|
24
|
+
* @property {RegExp} [sigRe] 签名/文本正则(无稳定错误码时用)
|
|
25
|
+
* @property {'check'|'skill'|'persona'} form 建议形态
|
|
26
|
+
* @property {string} why 给主人看的一句话理由(写清真实成因)
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** @type {FormRule[]} */
|
|
30
|
+
export const FORM_RULES = [
|
|
31
|
+
{
|
|
32
|
+
id: 'audit-not-passed',
|
|
33
|
+
codes: ['AUDIT_NOT_PASSED'],
|
|
34
|
+
form: 'skill',
|
|
35
|
+
why: '流程类(不是路径问题):写 persona / 技能 / 核心启动链前必须「先落临时文件 → butler-audit-cli 审计 → 用 write 写同一内容」;建议内联进 persona-maintain / P5 写入守门人相关技能',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: 'audit-hash-mismatch',
|
|
39
|
+
codes: ['AUDIT_HASH_MISMATCH'],
|
|
40
|
+
form: 'skill',
|
|
41
|
+
why: '流程类:审计凭证与被写内容 hash 绑定——一处一审计、改完再验 hash,staged 内容必须与写入内容逐字节一致;建议内联进 persona-maintain 技能',
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: 'fs-not-observed',
|
|
45
|
+
codes: ['FS_NOT_OBSERVED'],
|
|
46
|
+
form: 'persona',
|
|
47
|
+
why: '铁律级(全局、高频):改/覆盖已存在文件前必须先 read;已进 persona【铁律·edit/write 必须先读】,此处不再新增检查项',
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: 'fs-stale-version',
|
|
51
|
+
codes: ['FS_STALE_VERSION'],
|
|
52
|
+
form: 'persona',
|
|
53
|
+
why: '铁律级(全局):junction 路径与同文件多处修改必须重读后**串行 edit**(并行 edit 会互相刷新 mtime);已进 persona 对应铁律',
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: 'search-failed',
|
|
57
|
+
codes: ['SEARCH_FAILED'],
|
|
58
|
+
form: 'check',
|
|
59
|
+
why: '路径假设类:rg/grep/glob 目标不存在或不可达,可做成"搜索前先验路径存在"的只读检查项',
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
/** 快速查码表(大小写不敏感)。 */
|
|
64
|
+
const CODE_INDEX = new Map();
|
|
65
|
+
for (const r of FORM_RULES) {
|
|
66
|
+
for (const c of r.codes ?? []) CODE_INDEX.set(String(c).toUpperCase(), r);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** SCREAMING_SNAKE 错误码(至少含一个下划线,避免把 FsError 之类类名当码)。 */
|
|
70
|
+
export const ERROR_CODE_RE = /\b([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+)\b/g;
|
|
71
|
+
|
|
72
|
+
/** 不是错误码的占位(signatureOf 在拿不到 code 时写 NO_CODE)。 */
|
|
73
|
+
const NOT_A_CODE = new Set(['NO_CODE']);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 按优先级提取这条坑里出现的错误码:code 字段 → sigKey 第 2 段 → 文本中出现的码。
|
|
77
|
+
* @param {object} pitfall
|
|
78
|
+
* @returns {string[]} 去重后的候选码(越靠前越权威)
|
|
79
|
+
*/
|
|
80
|
+
export function extractErrorCodes(pitfall) {
|
|
81
|
+
const out = [];
|
|
82
|
+
const push = (v) => {
|
|
83
|
+
const s = String(v ?? '').toUpperCase().trim();
|
|
84
|
+
if (!s || NOT_A_CODE.has(s)) return;
|
|
85
|
+
if (!/^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/.test(s)) return;
|
|
86
|
+
if (!out.includes(s)) out.push(s);
|
|
87
|
+
};
|
|
88
|
+
push(pitfall?.code);
|
|
89
|
+
|
|
90
|
+
const sig = String(pitfall?.sigKey ?? '');
|
|
91
|
+
if (sig) {
|
|
92
|
+
const seg = sig.split('|')[1];
|
|
93
|
+
push(seg);
|
|
94
|
+
for (const m of sig.matchAll(ERROR_CODE_RE)) push(m[1]);
|
|
95
|
+
}
|
|
96
|
+
const text = String(pitfall?.normText ?? '');
|
|
97
|
+
if (text) for (const m of text.matchAll(ERROR_CODE_RE)) push(m[1]);
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 查表:错误码优先,其次签名正则。
|
|
103
|
+
* @param {object} pitfall
|
|
104
|
+
* @returns {{form:'check'|'skill'|'persona', why:string, ruleId:string, via:'code'|'sig', matched:string}|null}
|
|
105
|
+
* 未命中返回 null(调用方回落启发式)
|
|
106
|
+
*/
|
|
107
|
+
export function lookupFormByMap(pitfall) {
|
|
108
|
+
const codes = extractErrorCodes(pitfall);
|
|
109
|
+
for (const c of codes) {
|
|
110
|
+
const rule = CODE_INDEX.get(c);
|
|
111
|
+
if (rule) return { form: rule.form, why: `【映射表 ${rule.id}】${rule.why}`, ruleId: rule.id, via: 'code', matched: c };
|
|
112
|
+
}
|
|
113
|
+
const hay = `${pitfall?.name ?? ''}|${pitfall?.code ?? ''}|${pitfall?.sigKey ?? ''}|${pitfall?.normText ?? ''}`;
|
|
114
|
+
for (const rule of FORM_RULES) {
|
|
115
|
+
if (rule.sigRe && rule.sigRe.test(hay)) {
|
|
116
|
+
return { form: rule.form, why: `【映射表 ${rule.id}】${rule.why}`, ruleId: rule.id, via: 'sig', matched: String(rule.sigRe) };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 该错误码是否在表内(面板/调试用)。 */
|
|
123
|
+
export function isMappedCode(code) {
|
|
124
|
+
return CODE_INDEX.has(String(code ?? '').toUpperCase());
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 表内全部错误码(单测断言用)。 */
|
|
128
|
+
export function mappedCodes() {
|
|
129
|
+
return [...CODE_INDEX.keys()].sort();
|
|
130
|
+
}
|