dsh-plugin-manager-companion 0.1.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.en.md +144 -0
- package/README.md +142 -0
- package/cordis.patch.yml +9 -0
- package/dist/about.d.ts +77 -0
- package/dist/about.js +179 -0
- package/dist/cli.d.ts +226 -0
- package/dist/cli.js +856 -0
- package/dist/client/AboutPage.d.ts +75 -0
- package/dist/client/ConsolePage.d.ts +79 -0
- package/dist/client/KindsPage.d.ts +21 -0
- package/dist/client/MarketplacePage.d.ts +36 -0
- package/dist/client/OfficialSlots.d.ts +35 -0
- package/dist/client/UpgradeRow.d.ts +108 -0
- package/dist/client/index.d.ts +26 -0
- package/dist/client/locales.d.ts +475 -0
- package/dist/client/pmSelect.d.ts +38 -0
- package/dist/client/shared.d.ts +928 -0
- package/dist/client/upgradeView.d.ts +278 -0
- package/dist/client/wire.d.ts +401 -0
- package/dist/client.js +9194 -0
- package/dist/diagnostics.d.ts +332 -0
- package/dist/diagnostics.js +2631 -0
- package/dist/envManager.d.ts +1047 -0
- package/dist/envManager.js +3214 -0
- package/dist/fix.d.ts +60 -0
- package/dist/fix.js +168 -0
- package/dist/guard.d.ts +133 -0
- package/dist/guard.js +232 -0
- package/dist/index.d.ts +121 -0
- package/dist/index.js +1150 -0
- package/dist/installSession.d.ts +111 -0
- package/dist/installSession.js +150 -0
- package/dist/kinds.d.ts +464 -0
- package/dist/kinds.js +1029 -0
- package/dist/marketView.d.ts +261 -0
- package/dist/marketView.js +406 -0
- package/dist/marketplace.d.ts +248 -0
- package/dist/marketplace.js +500 -0
- package/dist/match.d.ts +67 -0
- package/dist/match.js +203 -0
- package/dist/net.d.ts +108 -0
- package/dist/net.js +163 -0
- package/dist/official.d.ts +145 -0
- package/dist/official.js +205 -0
- package/dist/paths.d.ts +108 -0
- package/dist/paths.js +236 -0
- package/dist/presets.d.ts +299 -0
- package/dist/presets.js +578 -0
- package/dist/qualityGate.d.ts +66 -0
- package/dist/qualityGate.js +247 -0
- package/dist/rank.d.ts +88 -0
- package/dist/rank.js +164 -0
- package/dist/registry.d.ts +295 -0
- package/dist/registry.js +686 -0
- package/dist/rest.d.ts +122 -0
- package/dist/rest.js +219 -0
- package/dist/scan.d.ts +134 -0
- package/dist/scan.js +396 -0
- package/dist/settings.d.ts +447 -0
- package/dist/settings.js +263 -0
- package/dist/tags.d.ts +119 -0
- package/dist/tags.js +166 -0
- package/dist/tools.d.ts +131 -0
- package/dist/tools.js +377 -0
- package/dist/types.d.ts +651 -0
- package/dist/types.js +13 -0
- package/dist/upgrade.d.ts +428 -0
- package/dist/upgrade.js +1100 -0
- package/dist/upgradeView.d.ts +313 -0
- package/dist/upgradeView.js +273 -0
- package/docs/images/readme/01-console-health.png +0 -0
- package/docs/images/readme/02-console-envs.png +0 -0
- package/docs/images/readme/03-marketplace.png +0 -0
- package/docs/images/readme/04-official-plugin-page.png +0 -0
- package/package.json +104 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 安装前质量门 — 对**已经装进 profile 的包**做静态体检(不写文件、不调 pnpm)。
|
|
3
|
+
*
|
|
4
|
+
* 归属:A 类·重写(旧 src/installFlow.ts#qualityIssues 只作检查面意图参考,未复制代码)。
|
|
5
|
+
* 旧实现参考:dsh-web-plugin-manager/src/installFlow.ts#qualityIssues(约 1422 行起)与
|
|
6
|
+
* docs/private/audit/correctness.md 的 C-1/C-2 记录。旧实现的两个 Critical 缺陷在这里被正面修掉:
|
|
7
|
+
* C-1 只扫 exports["."] 一个入口 → 现在扫全部导出子路径 + 相对 import 可达文件(有界 BFS);
|
|
8
|
+
* C-2 对 scoped 子路径假阳性(只要 @scope/pkg 目录存在就判"可解析")→ 现在用
|
|
9
|
+
* diagnostics.ts 的 specifierResolves:Node 真实解析(只用于判真)与文件/exports 探测两把尺子,
|
|
10
|
+
* 判定"不可解析"时必须两者都不成立。
|
|
11
|
+
* 官方复用:安装流程本身由官方 Remote 编排(inspect → installBundle(enabled:false) → 本门 →
|
|
12
|
+
* removeBundle / setBundleEnabled),本模块只提供第 3 步的静态判定,不参与写路径。
|
|
13
|
+
* 包入口解析、import 扫描、行号定位与 diagnostics.ts 共用同一套实现,避免两处口径漂移。
|
|
14
|
+
* 解析根:与诊断同源——profile 层 → 共享兜底层 → **安装锚点**(官方包与 bundle 本体由安装侧
|
|
15
|
+
* 提供,只看 profile 会把 peer 与 bundle 行全判成缺包)。installAnchor 省略时退回 profile 解析,
|
|
16
|
+
* 调用方应当把 ctx.get('profileContext').installAnchor 递进来。
|
|
17
|
+
* 前提检查:旧前提是"装完只看一个入口就够"——实测不够(C-1);旧实现还用自建 bash 探测
|
|
18
|
+
* patch 行名,现在改为读原始 patch 文本定位行 + Node 侧解析,不再自建第二套解析器。
|
|
19
|
+
*
|
|
20
|
+
* 调用契约:config.qualityGate 的 enabled/mode 是**调用方**(安装流程包装器)的事——
|
|
21
|
+
* enabled=false 时调用方不该调用本函数,mode='warn' 时调用方只展示不过滤。
|
|
22
|
+
* 本函数只消费 allowlist(命中即跳过全部检查,用户显式承担风险)。
|
|
23
|
+
*/
|
|
24
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
25
|
+
import { isBuiltin } from 'node:module';
|
|
26
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
27
|
+
import { OFFICIAL_DEP_ALLOWED, installedPackageDir, isLoaderProvided, locatePatchRows, packageEntryFiles, readInstallAnchor, scanPackage, specifierResolves, } from "./diagnostics.js";
|
|
28
|
+
/** 质量门单包扫描的文件预算(覆盖整条加载链,同时保证响应有界)。 */
|
|
29
|
+
const GATE_FILE_BUDGET = 400;
|
|
30
|
+
/**
|
|
31
|
+
* 体检一个已安装的包。
|
|
32
|
+
*
|
|
33
|
+
* 检查三件事:
|
|
34
|
+
* 1. 未声明的 import(loader/平台提供项与 Node 内置模块豁免)——挂载后必 ERR_MODULE_NOT_FOUND;
|
|
35
|
+
* 2. 声明了但没装(peer 或普通依赖落不下来);
|
|
36
|
+
* 3. @deepseek-ai/* 被声明成普通 dependencies —— 模块身份分裂会劫持官方 loader 行(豁免见
|
|
37
|
+
* diagnostics.ts 的 OFFICIAL_DEP_ALLOWED)。
|
|
38
|
+
* 另外检查包自己的 bundle patch 行名是否解析得到(该行解析失败会让整个 profile 起不来)。
|
|
39
|
+
*
|
|
40
|
+
* @param envDir - profile 环境目录(解析锚点,也是包所在 node_modules 的父目录)。
|
|
41
|
+
* @param packageName - 已安装的包名。
|
|
42
|
+
* @param config - 本插件配置;同时接受 CompanionConfig 与单独的 qualityGate 段。
|
|
43
|
+
* @param ctxOrAnchor - host Context(从 ctx.get('profileContext') 取安装锚点),
|
|
44
|
+
* 或直接给 dsh 应用包的 package.json 路径;都不给时只按 profile 与共享兜底层解析,
|
|
45
|
+
* 安装侧提供的包会被判成缺包。
|
|
46
|
+
* @returns 判定结果;问题为空即 ok。
|
|
47
|
+
*/
|
|
48
|
+
export async function inspectPackage(envDir, packageName, config, ctxOrAnchor) {
|
|
49
|
+
const installAnchor = typeof ctxOrAnchor === 'string'
|
|
50
|
+
? ctxOrAnchor
|
|
51
|
+
: ctxOrAnchor === undefined ? undefined : readInstallAnchor(ctxOrAnchor);
|
|
52
|
+
const gate = normalizeQualityGateConfig(config);
|
|
53
|
+
const issues = [];
|
|
54
|
+
const notes = [];
|
|
55
|
+
if (gate.allowlist.includes(packageName)) {
|
|
56
|
+
return {
|
|
57
|
+
ok: true,
|
|
58
|
+
issues,
|
|
59
|
+
notes: ['包名在质量门豁免名单里:跳过全部检查(这是用户显式承担风险的选择)'],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
if (!isSafePackageName(packageName)) {
|
|
63
|
+
return { ok: false, issues: ['包名不合法:' + JSON.stringify(packageName)], notes };
|
|
64
|
+
}
|
|
65
|
+
const pkgDir = installedPackageDir(envDir, packageName, installAnchor);
|
|
66
|
+
if (pkgDir === undefined) {
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
issues: ['在 ' + envDir + ' 下的 node_modules 里找不到这个包:质量门要求包先落地再体检'],
|
|
70
|
+
notes,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const manifest = readManifest(join(pkgDir, 'package.json'));
|
|
74
|
+
if (manifest === undefined) {
|
|
75
|
+
return { ok: false, issues: ['读不到该包的 package.json(缺失或不是 JSON 对象)'], notes };
|
|
76
|
+
}
|
|
77
|
+
const declared = declaredDependencyNames(manifest);
|
|
78
|
+
const regular = Object.keys(asRecord(manifest['dependencies']) ?? {});
|
|
79
|
+
for (const dep of regular) {
|
|
80
|
+
if (!dep.startsWith('@deepseek-ai/'))
|
|
81
|
+
continue;
|
|
82
|
+
if (OFFICIAL_DEP_ALLOWED.has(dep)) {
|
|
83
|
+
notes.push(dep + ' 属于模块身份不敏感的官方包,普通依赖声明按豁免处理');
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
issues.push('把官方包 ' + dep + ' 声明成了普通 dependencies:pnpm 会在 profile 里装出第二份拷贝,'
|
|
87
|
+
+ 'loader 解析官方行时用到它,唯一符号与类身份分裂(典型症状是 '
|
|
88
|
+
+ 'Cannot read properties of undefined (reading \'prepare\'))。'
|
|
89
|
+
+ '请改成 peerDependencies(peer 由安装兜底层满足),或删掉这条声明。');
|
|
90
|
+
}
|
|
91
|
+
const entries = packageEntryFiles(pkgDir, manifest);
|
|
92
|
+
if (entries.length === 0) {
|
|
93
|
+
issues.push('没有可解析的入口文件(exports / main / module / index.js 都没有命中):'
|
|
94
|
+
+ '任何挂载它的 loader 行都会失败。');
|
|
95
|
+
}
|
|
96
|
+
const scan = scanPackage(pkgDir, manifest, GATE_FILE_BUDGET);
|
|
97
|
+
if (scan.reason !== undefined && entries.length > 0)
|
|
98
|
+
notes.push('扫描未完成:' + scan.reason);
|
|
99
|
+
if (scan.truncated) {
|
|
100
|
+
notes.push('扫描在 ' + scan.filesScanned + ' 个文件处到达上限(单包预算 ' + GATE_FILE_BUDGET
|
|
101
|
+
+ ' 个文件),更深处的 import 未覆盖:标为通过不代表已证全善。');
|
|
102
|
+
}
|
|
103
|
+
const at = (file, line) => relativeTo(envDir, file) + ':' + line;
|
|
104
|
+
for (const hit of scan.imports) {
|
|
105
|
+
const spec = hit.spec;
|
|
106
|
+
if (isBuiltinSpecifier(spec))
|
|
107
|
+
continue;
|
|
108
|
+
if (isLoaderProvided(spec))
|
|
109
|
+
continue;
|
|
110
|
+
if (spec === packageName || spec.startsWith(packageName + '/'))
|
|
111
|
+
continue;
|
|
112
|
+
const covered = [...declared].some(name => spec === name || spec.startsWith(name + '/'));
|
|
113
|
+
if (!covered) {
|
|
114
|
+
issues.push('未声明的 import:' + at(hit.file, hit.line) + ' 导入 ' + spec
|
|
115
|
+
+ ',但本包的 dependencies / peerDependencies 都没有声明它,profile 里也不会装它——'
|
|
116
|
+
+ '挂载后必然 ERR_MODULE_NOT_FOUND(未声明依赖 pnpm 根本不会安装)。');
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (!specifierResolves(pkgDir, spec, installAnchor) && !specifierResolves(envDir, spec, installAnchor)) {
|
|
120
|
+
issues.push('声明了但没装:' + spec + '(声明于本包 package.json),'
|
|
121
|
+
+ '在 ' + at(hit.file, hit.line) + ' 被导入,但 profile 与共享兜底层里都解析不到它——'
|
|
122
|
+
+ '挂载后必然 ERR_MODULE_NOT_FOUND。');
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
issues.push(...bundleRowIssues(envDir, pkgDir, packageName, installAnchor));
|
|
126
|
+
return { ok: issues.length === 0, issues, notes };
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* 包自带 bundle patch 的行名检查。
|
|
130
|
+
*
|
|
131
|
+
* 一行 name 解析不到,挂载时就是 ERR_MODULE_NOT_FOUND,而且会让**整个 profile** 起不来
|
|
132
|
+
* (bundle 的行是启动路径的一部分)。这里用 diagnostics 的行定位器读原始 patch 文本,
|
|
133
|
+
* 解析判定与诊断层完全同源。
|
|
134
|
+
*
|
|
135
|
+
* @param envDir - profile 环境目录(解析锚点)。
|
|
136
|
+
* @param pkgDir - 包目录。
|
|
137
|
+
* @param packageName - 包名。
|
|
138
|
+
* @param installAnchor - dsh 应用包的 package.json(解析根,见 inspectPackage)。
|
|
139
|
+
* @returns 面向用户的问题清单(可能为空)。
|
|
140
|
+
*/
|
|
141
|
+
function bundleRowIssues(envDir, pkgDir, packageName, installAnchor) {
|
|
142
|
+
const issues = [];
|
|
143
|
+
const manifest = readManifest(join(pkgDir, 'package.json'));
|
|
144
|
+
const bundle = asRecord(manifest?.['dsh'])?.['bundle'];
|
|
145
|
+
const declared = asRecord(bundle)?.['patch'];
|
|
146
|
+
if (typeof declared !== 'string' || declared.length === 0)
|
|
147
|
+
return issues;
|
|
148
|
+
const patchPath = resolve(pkgDir, declared);
|
|
149
|
+
const patchLabel = relativeTo(envDir, patchPath);
|
|
150
|
+
if (!existsSync(patchPath)) {
|
|
151
|
+
issues.push('声明了 dsh.bundle.patch=' + declared + ',但 ' + patchLabel
|
|
152
|
+
+ ' 不存在:profile 启动时读不到这一层 patch。');
|
|
153
|
+
return issues;
|
|
154
|
+
}
|
|
155
|
+
for (const row of locatePatchRows(patchPath)) {
|
|
156
|
+
const name = row.name;
|
|
157
|
+
if (name === undefined || name.length === 0)
|
|
158
|
+
continue;
|
|
159
|
+
const at = patchLabel + ':' + row.line;
|
|
160
|
+
if (name.startsWith('cordis:'))
|
|
161
|
+
continue;
|
|
162
|
+
if (name === packageName || name.startsWith(packageName + '/')) {
|
|
163
|
+
if (!specifierResolves(envDir, name, installAnchor)) {
|
|
164
|
+
issues.push('bundle patch 行指向自身子路径 ' + name + '(' + at + '),但该子路径解析不到:'
|
|
165
|
+
+ '要么文件不存在,要么没写进 exports。挂载这一行会让 profile 起不来。');
|
|
166
|
+
}
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (name.startsWith('.') || isAbsolute(name)) {
|
|
170
|
+
if (!existsSync(resolve(dirname(patchPath), name))) {
|
|
171
|
+
issues.push('bundle patch 行 ' + at + ' 的相对模块名 ' + name + ' 解析不到文件'
|
|
172
|
+
+ '(相对路径按 patch 文件所在目录解析)。');
|
|
173
|
+
}
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (isLoaderProvided(name))
|
|
177
|
+
continue;
|
|
178
|
+
if (!specifierResolves(envDir, name, installAnchor)) {
|
|
179
|
+
issues.push('bundle patch 行 ' + at + ' 挂载 ' + name + ',但它在 profile、共享兜底层与安装锚点下都'
|
|
180
|
+
+ '解析不到:挂载这一行会让整个 profile 起不来(这也是本地安装的 bundle 依赖漏装时的典型症状)。');
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return issues;
|
|
184
|
+
}
|
|
185
|
+
/** 组装 CompanionConfig 与单独 qualityGate 段的差异。 */
|
|
186
|
+
function normalizeQualityGateConfig(config) {
|
|
187
|
+
const candidate = config.qualityGate;
|
|
188
|
+
return candidate ?? {
|
|
189
|
+
enabled: true,
|
|
190
|
+
mode: 'block',
|
|
191
|
+
allowlist: config.allowlist ?? [],
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* 包名安全校验:质量门要把包名拼进路径,必须挡住路径穿越
|
|
196
|
+
* (与 paths.ts 的 isSafeEnvironmentName 同样的纵深防御思路)。
|
|
197
|
+
*/
|
|
198
|
+
export function isSafePackageName(name) {
|
|
199
|
+
if (name.length === 0 || name.length > 214)
|
|
200
|
+
return false;
|
|
201
|
+
if (name.startsWith('.') || name.startsWith('/') || name.startsWith('\\'))
|
|
202
|
+
return false;
|
|
203
|
+
if (name.includes('..') || name.includes('\\'))
|
|
204
|
+
return false;
|
|
205
|
+
return /^(@[a-z0-9._-]+\/)?[a-z0-9._-]+$/i.test(name);
|
|
206
|
+
}
|
|
207
|
+
/** Node 内置模块说明符:crypto 与 node:crypto 等价,fs/promises 这类子路径同样豁免。 */
|
|
208
|
+
export function isBuiltinSpecifier(spec) {
|
|
209
|
+
if (spec.startsWith('node:'))
|
|
210
|
+
return true;
|
|
211
|
+
return isBuiltin(spec);
|
|
212
|
+
}
|
|
213
|
+
/** 一个包声明的全部依赖名(dependencies + peerDependencies + optionalDependencies)。 */
|
|
214
|
+
function declaredDependencyNames(manifest) {
|
|
215
|
+
const names = new Set();
|
|
216
|
+
for (const section of ['dependencies', 'peerDependencies', 'optionalDependencies']) {
|
|
217
|
+
const record = asRecord(manifest[section]);
|
|
218
|
+
if (record === undefined)
|
|
219
|
+
continue;
|
|
220
|
+
for (const name of Object.keys(record))
|
|
221
|
+
names.add(name);
|
|
222
|
+
}
|
|
223
|
+
return names;
|
|
224
|
+
}
|
|
225
|
+
/** 读一个包 manifest;读不到或不是 JSON 对象时 undefined。 */
|
|
226
|
+
function readManifest(path) {
|
|
227
|
+
try {
|
|
228
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
229
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
230
|
+
return undefined;
|
|
231
|
+
return parsed;
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
/** 取对象字段(非对象返回 undefined)。 */
|
|
238
|
+
function asRecord(value) {
|
|
239
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
240
|
+
return undefined;
|
|
241
|
+
return value;
|
|
242
|
+
}
|
|
243
|
+
/** 相对路径(不在目录内时返回绝对路径)。 */
|
|
244
|
+
function relativeTo(base, file) {
|
|
245
|
+
const rel = relative(base, file);
|
|
246
|
+
return rel.length === 0 || rel.startsWith('..') ? file : rel;
|
|
247
|
+
}
|
package/dist/rank.d.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rank.ts — 名称模糊打分:字符掩码预筛 + 有序子序列对齐(纯函数,无 fs / 无 ctx / 无网络)。
|
|
3
|
+
*
|
|
4
|
+
* 归属:B 类·参考重写(旧 src/rank.ts 只作意图参考,未复制代码;算法按官方 rankByName 的语义重写并做长名适配)。
|
|
5
|
+
* 旧实现参考:dsh-web-plugin-manager/src/rank.ts(理解意图用,未复制代码)——它解决的问题是:
|
|
6
|
+
* 市场的搜索框需要一个**可预测**的模糊匹配(官方设计记录明确否决了无序字符匹配与第三方
|
|
7
|
+
* fuzzy 依赖),并且 host 单测与 client bundle 必须共用同一实现,两处行为一致。
|
|
8
|
+
* 官方复用:**已核查,结论是不能直接复用**。官方 @deepseek-ai/dsh-client-ui-primitives
|
|
9
|
+
* 0.1.6-alpha.2 确实导出了 rankByName(lib/types/index.d.ts 有声明),但:
|
|
10
|
+
* 1) 它的运行时入口 lib/index.js 在顶层 import `./Tag.module.css` 之类的 CSS Modules 与
|
|
11
|
+
* react/react-dom,从 host 进程 import 直接失败(实测 `node -e "import('@deepseek-ai/dsh-client-ui-primitives')"`
|
|
12
|
+
* → `Cannot find package 'clsx' imported from .../lib/index.js`)。本模块的目标编译位
|
|
13
|
+
* (tsconfig.host.json 的 src/*.ts)与 node 单测都在 host 侧,因此"直接复用"不可行。
|
|
14
|
+
* 2) 即便在 client 侧能 import(它是平台模块),也没法两边共用:host 侧仍需要同一套打分
|
|
15
|
+
* (plugin_search 的排序与页面必须一致)。两套实现必然漂移,所以统一走本模块。
|
|
16
|
+
* 3) 语义上它面向**短命令名**(首字符用全局 -index 惩罚晚起始),套到长仓库名上会累计成大负分:
|
|
17
|
+
* 旧仓库实测 trmnl → dsh-terminal-panel 得 -14,反而输给 1 分的描述兜底命中。故本模块对它
|
|
18
|
+
* 做了长名适配(见 fuzzyScoreLowered 的注释),这也是任务里"长名适配版"的含义。
|
|
19
|
+
* 前提检查:旧实现的两条打分规则仍然成立(边界加分、连续命中加分),适配的三条被保留并显式化:
|
|
20
|
+
* 首字符晚起始**有界**扣分、间隔按实际间距扣分、总分下限 1。
|
|
21
|
+
* 旧 perf 审计(docs/private/audit/perf.md §8)量化了预筛收益(13k 键击 24–33ms → 1.0–9.6ms),
|
|
22
|
+
* 并**实测否决**了 DP 数组缓冲复用(更慢且引入模块级可变状态),所以这里保持每次新分配的纯函数实现。
|
|
23
|
+
*/
|
|
24
|
+
/** 26 位 ASCII 字母掩码(a–z)。 */
|
|
25
|
+
export type CharMask = number;
|
|
26
|
+
/** 命中(含稳定排序键)。 */
|
|
27
|
+
export interface FuzzyHit<T> {
|
|
28
|
+
readonly item: T;
|
|
29
|
+
/** 输入中的原始下标:同分时按它排序,使顺序不依赖 Array.sort 的稳定性。 */
|
|
30
|
+
readonly index: number;
|
|
31
|
+
/** 对齐分(越大越好,恒 ≥ 1)。 */
|
|
32
|
+
readonly score: number;
|
|
33
|
+
/** 前缀命中;排序时优先于对齐分。 */
|
|
34
|
+
readonly prefix: boolean;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* 计算字符掩码:a–z 映射到 26 个 bit。
|
|
38
|
+
*
|
|
39
|
+
* 只覆盖 ASCII 字母,且大小写都置位(调用方通常传小写串,兼容大写是为了防误用)。
|
|
40
|
+
* 非 ASCII 字符(CJK / emoji)**不进掩码**,因此掩码只会"放过"而绝不会误杀——
|
|
41
|
+
* 这是它能当预筛用的前提:子序列的必要条件是字符集合的超集关系。
|
|
42
|
+
*
|
|
43
|
+
* @param value - 待测字符串。
|
|
44
|
+
* @returns 26 位掩码。
|
|
45
|
+
*/
|
|
46
|
+
export declare function charMask(value: string): CharMask;
|
|
47
|
+
/**
|
|
48
|
+
* 模糊打分(大小写不敏感)。
|
|
49
|
+
*
|
|
50
|
+
* @param name - 候选名。
|
|
51
|
+
* @param query - 用户输入。
|
|
52
|
+
* @returns 对齐分(≥1);query 不是 name 的有序子序列时返回 null(淘汰)。
|
|
53
|
+
*/
|
|
54
|
+
export declare function fuzzyScore(name: string, query: string): number | null;
|
|
55
|
+
/**
|
|
56
|
+
* 预筛版打分(调用方已把小写串与掩码算好的快路径)。
|
|
57
|
+
*
|
|
58
|
+
* 语义:整条 needle 必须作为 haystack 的**有序子序列**出现(不分词),取最优对齐分。
|
|
59
|
+
* 打分规则(对官方的长名适配):
|
|
60
|
+
* - 每个命中位给 `1 + 边界加分`;
|
|
61
|
+
* - 上一个 needle 字符恰好在前一位时额外 +4(连续命中强加分);
|
|
62
|
+
* - 间隔命中按实际间距扣分(`-(间距-1)`);
|
|
63
|
+
* - 首字符晚起始的扣分**有界**(`-min(i, 6)`)——官方用无界的 `-index`,在长仓库名上会
|
|
64
|
+
* 把"散落在后半段的合法命中"压成大负分,反而输给 1 分的兜底命中;
|
|
65
|
+
* - 总分下限 1:名称命中恒不低于兜底命中(调用方用"描述子串 = 1 分"兜底)。
|
|
66
|
+
*
|
|
67
|
+
* 时间 O(n·m),空间 O(n);不做缓冲复用(旧 perf 审计实测复用更慢,且会引入模块级可变状态)。
|
|
68
|
+
*
|
|
69
|
+
* @param haystack - 候选名(调用方保证已小写)。
|
|
70
|
+
* @param needle - 查询串(调用方保证已小写)。
|
|
71
|
+
* @param hayMask - 可选的 {@link charMask}(haystack):与 needleMask 同时给出时启用 O(1) 预筛。
|
|
72
|
+
* @param needleMask - 可选的 {@link charMask}(needle)。掩码只做**拒绝**、不参与打分,
|
|
73
|
+
* 因此传与不传的命中集合与分数逐条相同(tests/rank.test.mjs 有等价性断言)。
|
|
74
|
+
* @returns 最优对齐分;不是子序列时返回 null。
|
|
75
|
+
*/
|
|
76
|
+
export declare function fuzzyScoreLowered(haystack: string, needle: string, hayMask?: CharMask, needleMask?: CharMask): number | null;
|
|
77
|
+
/**
|
|
78
|
+
* 过滤 + 排序(前缀命中 → 对齐分 → 原始下标)。
|
|
79
|
+
*
|
|
80
|
+
* 排序键里带 `index`,所以同分条目的顺序由输入顺序唯一决定,不依赖引擎的排序稳定性——
|
|
81
|
+
* 这是"稳定全序"要求的直接落实。
|
|
82
|
+
*
|
|
83
|
+
* @param items - 候选条目(保持调用方给的顺序)。
|
|
84
|
+
* @param nameOf - 取名称的函数(例如 `item => item.name`)。
|
|
85
|
+
* @param query - 用户输入;**空白查询返回 null**(调用方走自己的默认路径,不要在这里猜测默认顺序)。
|
|
86
|
+
* @returns 命中列表;查询为空时 null。
|
|
87
|
+
*/
|
|
88
|
+
export declare function fuzzyFilter<T>(items: readonly T[], nameOf: (item: T) => string, query: string): FuzzyHit<T>[] | null;
|
package/dist/rank.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rank.ts — 名称模糊打分:字符掩码预筛 + 有序子序列对齐(纯函数,无 fs / 无 ctx / 无网络)。
|
|
3
|
+
*
|
|
4
|
+
* 归属:B 类·参考重写(旧 src/rank.ts 只作意图参考,未复制代码;算法按官方 rankByName 的语义重写并做长名适配)。
|
|
5
|
+
* 旧实现参考:dsh-web-plugin-manager/src/rank.ts(理解意图用,未复制代码)——它解决的问题是:
|
|
6
|
+
* 市场的搜索框需要一个**可预测**的模糊匹配(官方设计记录明确否决了无序字符匹配与第三方
|
|
7
|
+
* fuzzy 依赖),并且 host 单测与 client bundle 必须共用同一实现,两处行为一致。
|
|
8
|
+
* 官方复用:**已核查,结论是不能直接复用**。官方 @deepseek-ai/dsh-client-ui-primitives
|
|
9
|
+
* 0.1.6-alpha.2 确实导出了 rankByName(lib/types/index.d.ts 有声明),但:
|
|
10
|
+
* 1) 它的运行时入口 lib/index.js 在顶层 import `./Tag.module.css` 之类的 CSS Modules 与
|
|
11
|
+
* react/react-dom,从 host 进程 import 直接失败(实测 `node -e "import('@deepseek-ai/dsh-client-ui-primitives')"`
|
|
12
|
+
* → `Cannot find package 'clsx' imported from .../lib/index.js`)。本模块的目标编译位
|
|
13
|
+
* (tsconfig.host.json 的 src/*.ts)与 node 单测都在 host 侧,因此"直接复用"不可行。
|
|
14
|
+
* 2) 即便在 client 侧能 import(它是平台模块),也没法两边共用:host 侧仍需要同一套打分
|
|
15
|
+
* (plugin_search 的排序与页面必须一致)。两套实现必然漂移,所以统一走本模块。
|
|
16
|
+
* 3) 语义上它面向**短命令名**(首字符用全局 -index 惩罚晚起始),套到长仓库名上会累计成大负分:
|
|
17
|
+
* 旧仓库实测 trmnl → dsh-terminal-panel 得 -14,反而输给 1 分的描述兜底命中。故本模块对它
|
|
18
|
+
* 做了长名适配(见 fuzzyScoreLowered 的注释),这也是任务里"长名适配版"的含义。
|
|
19
|
+
* 前提检查:旧实现的两条打分规则仍然成立(边界加分、连续命中加分),适配的三条被保留并显式化:
|
|
20
|
+
* 首字符晚起始**有界**扣分、间隔按实际间距扣分、总分下限 1。
|
|
21
|
+
* 旧 perf 审计(docs/private/audit/perf.md §8)量化了预筛收益(13k 键击 24–33ms → 1.0–9.6ms),
|
|
22
|
+
* 并**实测否决**了 DP 数组缓冲复用(更慢且引入模块级可变状态),所以这里保持每次新分配的纯函数实现。
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* 计算字符掩码:a–z 映射到 26 个 bit。
|
|
26
|
+
*
|
|
27
|
+
* 只覆盖 ASCII 字母,且大小写都置位(调用方通常传小写串,兼容大写是为了防误用)。
|
|
28
|
+
* 非 ASCII 字符(CJK / emoji)**不进掩码**,因此掩码只会"放过"而绝不会误杀——
|
|
29
|
+
* 这是它能当预筛用的前提:子序列的必要条件是字符集合的超集关系。
|
|
30
|
+
*
|
|
31
|
+
* @param value - 待测字符串。
|
|
32
|
+
* @returns 26 位掩码。
|
|
33
|
+
*/
|
|
34
|
+
export function charMask(value) {
|
|
35
|
+
let mask = 0;
|
|
36
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
37
|
+
const code = value.charCodeAt(i);
|
|
38
|
+
if (code >= 97 && code <= 122)
|
|
39
|
+
mask |= 1 << (code - 97);
|
|
40
|
+
else if (code >= 65 && code <= 90)
|
|
41
|
+
mask |= 1 << (code - 65);
|
|
42
|
+
}
|
|
43
|
+
return mask;
|
|
44
|
+
}
|
|
45
|
+
/** 边界加分:命中位是名字首位,或前一个字符是分隔符(- / _)。 */
|
|
46
|
+
function boundaryBonus(name, index) {
|
|
47
|
+
if (index === 0)
|
|
48
|
+
return 8;
|
|
49
|
+
const previous = name.charAt(index - 1);
|
|
50
|
+
return previous === '-' || previous === '_' ? 8 : 0;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* 模糊打分(大小写不敏感)。
|
|
54
|
+
*
|
|
55
|
+
* @param name - 候选名。
|
|
56
|
+
* @param query - 用户输入。
|
|
57
|
+
* @returns 对齐分(≥1);query 不是 name 的有序子序列时返回 null(淘汰)。
|
|
58
|
+
*/
|
|
59
|
+
export function fuzzyScore(name, query) {
|
|
60
|
+
return fuzzyScoreLowered(name.toLowerCase(), query.toLowerCase());
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* 预筛版打分(调用方已把小写串与掩码算好的快路径)。
|
|
64
|
+
*
|
|
65
|
+
* 语义:整条 needle 必须作为 haystack 的**有序子序列**出现(不分词),取最优对齐分。
|
|
66
|
+
* 打分规则(对官方的长名适配):
|
|
67
|
+
* - 每个命中位给 `1 + 边界加分`;
|
|
68
|
+
* - 上一个 needle 字符恰好在前一位时额外 +4(连续命中强加分);
|
|
69
|
+
* - 间隔命中按实际间距扣分(`-(间距-1)`);
|
|
70
|
+
* - 首字符晚起始的扣分**有界**(`-min(i, 6)`)——官方用无界的 `-index`,在长仓库名上会
|
|
71
|
+
* 把"散落在后半段的合法命中"压成大负分,反而输给 1 分的兜底命中;
|
|
72
|
+
* - 总分下限 1:名称命中恒不低于兜底命中(调用方用"描述子串 = 1 分"兜底)。
|
|
73
|
+
*
|
|
74
|
+
* 时间 O(n·m),空间 O(n);不做缓冲复用(旧 perf 审计实测复用更慢,且会引入模块级可变状态)。
|
|
75
|
+
*
|
|
76
|
+
* @param haystack - 候选名(调用方保证已小写)。
|
|
77
|
+
* @param needle - 查询串(调用方保证已小写)。
|
|
78
|
+
* @param hayMask - 可选的 {@link charMask}(haystack):与 needleMask 同时给出时启用 O(1) 预筛。
|
|
79
|
+
* @param needleMask - 可选的 {@link charMask}(needle)。掩码只做**拒绝**、不参与打分,
|
|
80
|
+
* 因此传与不传的命中集合与分数逐条相同(tests/rank.test.mjs 有等价性断言)。
|
|
81
|
+
* @returns 最优对齐分;不是子序列时返回 null。
|
|
82
|
+
*/
|
|
83
|
+
export function fuzzyScoreLowered(haystack, needle, hayMask, needleMask) {
|
|
84
|
+
const m = needle.length;
|
|
85
|
+
if (m === 0)
|
|
86
|
+
return 0;
|
|
87
|
+
const n = haystack.length;
|
|
88
|
+
if (m > n)
|
|
89
|
+
return null;
|
|
90
|
+
if (hayMask !== undefined && needleMask !== undefined && (hayMask & needleMask) !== needleMask)
|
|
91
|
+
return null;
|
|
92
|
+
const none = Number.NEGATIVE_INFINITY;
|
|
93
|
+
/** 匹配到 needle 第 j 个字符、且**恰好落在**位置 i 的最优分。 */
|
|
94
|
+
let previous = new Float64Array(n).fill(none);
|
|
95
|
+
for (let i = 0; i < n; i += 1) {
|
|
96
|
+
if (haystack.charCodeAt(i) === needle.charCodeAt(0)) {
|
|
97
|
+
previous[i] = 1 + boundaryBonus(haystack, i) - Math.min(i, 6);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
for (let j = 1; j < m; j += 1) {
|
|
101
|
+
const needleCode = needle.charCodeAt(j);
|
|
102
|
+
const current = new Float64Array(n).fill(none);
|
|
103
|
+
// bestGapped = max over i' < i of (previous[i'] + i'):间隔命中的来源。
|
|
104
|
+
let bestGapped = none;
|
|
105
|
+
for (let i = 0; i < n; i += 1) {
|
|
106
|
+
if (haystack.charCodeAt(i) === needleCode) {
|
|
107
|
+
let score = none;
|
|
108
|
+
const adjacent = i > 0 ? previous[i - 1] : none;
|
|
109
|
+
if (adjacent !== none)
|
|
110
|
+
score = adjacent + 4;
|
|
111
|
+
if (bestGapped !== none) {
|
|
112
|
+
const gapped = bestGapped - (i - 1);
|
|
113
|
+
if (gapped > score)
|
|
114
|
+
score = gapped;
|
|
115
|
+
}
|
|
116
|
+
if (score !== none)
|
|
117
|
+
current[i] = score + 1 + boundaryBonus(haystack, i);
|
|
118
|
+
}
|
|
119
|
+
const prior = previous[i];
|
|
120
|
+
if (prior !== none) {
|
|
121
|
+
const shifted = prior + i;
|
|
122
|
+
if (shifted > bestGapped)
|
|
123
|
+
bestGapped = shifted;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
previous = current;
|
|
127
|
+
}
|
|
128
|
+
let best = none;
|
|
129
|
+
for (let i = 0; i < n; i += 1) {
|
|
130
|
+
const value = previous[i];
|
|
131
|
+
if (value > best)
|
|
132
|
+
best = value;
|
|
133
|
+
}
|
|
134
|
+
return best === none ? null : Math.max(best, 1);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* 过滤 + 排序(前缀命中 → 对齐分 → 原始下标)。
|
|
138
|
+
*
|
|
139
|
+
* 排序键里带 `index`,所以同分条目的顺序由输入顺序唯一决定,不依赖引擎的排序稳定性——
|
|
140
|
+
* 这是"稳定全序"要求的直接落实。
|
|
141
|
+
*
|
|
142
|
+
* @param items - 候选条目(保持调用方给的顺序)。
|
|
143
|
+
* @param nameOf - 取名称的函数(例如 `item => item.name`)。
|
|
144
|
+
* @param query - 用户输入;**空白查询返回 null**(调用方走自己的默认路径,不要在这里猜测默认顺序)。
|
|
145
|
+
* @returns 命中列表;查询为空时 null。
|
|
146
|
+
*/
|
|
147
|
+
export function fuzzyFilter(items, nameOf, query) {
|
|
148
|
+
const trimmed = query.trim();
|
|
149
|
+
if (trimmed.length === 0)
|
|
150
|
+
return null;
|
|
151
|
+
const needle = trimmed.toLowerCase();
|
|
152
|
+
const needleMask = charMask(needle);
|
|
153
|
+
const hits = [];
|
|
154
|
+
for (let index = 0; index < items.length; index += 1) {
|
|
155
|
+
const item = items[index];
|
|
156
|
+
const lowered = nameOf(item).toLowerCase();
|
|
157
|
+
const score = fuzzyScoreLowered(lowered, needle, charMask(lowered), needleMask);
|
|
158
|
+
if (score === null)
|
|
159
|
+
continue;
|
|
160
|
+
hits.push({ item, index, score, prefix: lowered.startsWith(needle) });
|
|
161
|
+
}
|
|
162
|
+
hits.sort((left, right) => Number(right.prefix) - Number(left.prefix) || right.score - left.score || left.index - right.index);
|
|
163
|
+
return hits;
|
|
164
|
+
}
|