@kira905/dsh-ecosystem-panel 0.0.0-stage → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +71 -0
- package/LICENSE +661 -0
- package/README.en.md +220 -0
- package/README.md +215 -2
- package/cordis.patch.yml +7 -0
- package/docs/RELEASING.md +68 -0
- package/examples/breaking-symbols.example.json +9 -0
- package/examples/ecosystem-panel.config.example.json +28 -0
- package/examples/labels.example.json +6 -0
- package/examples/patch-manifest.example.json +53 -0
- package/examples/upgrade-rules.example.json +28 -0
- package/lib/client.js +414 -0
- package/lib/config.js +362 -0
- package/lib/host-protocol.js +33 -0
- package/lib/index.js +59 -0
- package/lib/state.js +775 -0
- package/lib/upgrade.js +487 -0
- package/package.json +71 -4
- package/scripts/test-fixture.mjs +251 -0
- package/scripts/verify-source.mjs +278 -0
- package/scripts/verify-state.mjs +103 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/test-fixture.mjs —— 干净临时路径上的端到端夹具(**不需要任何真实 DSH 安装**)
|
|
3
|
+
//
|
|
4
|
+
// 干什么:在系统临时目录里现场造一个最小 DSH 数据根 ——
|
|
5
|
+
// · 假 profile(package.json 的 bundles/dependencies + cordis.patch.yml + node_modules 实体)
|
|
6
|
+
// · 假技能目录(一个合法 frontmatter、一个缺 description)
|
|
7
|
+
// · 假外部工具脚本 4 个(解耦检查 / 档位 CLI / 跨表校验器 / 适配层一致性校验器,都是用最简 stub)
|
|
8
|
+
// · 假宿主主包目录(用于 target=global 的补丁检查)
|
|
9
|
+
// 然后**直接驱动 lib/state.js 的取数层**(buildState),断言:
|
|
10
|
+
// ① 六类体检都能跑出结果、且各自的判定与预期一致;
|
|
11
|
+
// ② **缺数据必显式降级**(不可探测 + 原因),绝不显示 0 或假绿;
|
|
12
|
+
// ③ 配置层停用类目 / 来源自证 / 坏输入回落这些边界也对。
|
|
13
|
+
//
|
|
14
|
+
// 全程不读、不写任何真实安装;唯一的外部访问是 npm registry(升级体检的"远端最新版",
|
|
15
|
+
// 拿不到只会变成 errors 里的记录,不影响任何断言)。
|
|
16
|
+
//
|
|
17
|
+
// 用法:node scripts/test-fixture.mjs
|
|
18
|
+
// 退出码:0 = 全通过;1 = 有失败项。
|
|
19
|
+
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import os from 'node:os';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
24
|
+
|
|
25
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
26
|
+
let pass = 0;
|
|
27
|
+
let fail = 0;
|
|
28
|
+
const ok = (name, cond, detail = '') => {
|
|
29
|
+
if (cond) { pass += 1; console.log('[OK] ' + name + (detail ? ' :: ' + detail : '')); }
|
|
30
|
+
else { fail += 1; console.log('[FAIL] ' + name + (detail ? ' :: ' + detail : '')); }
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------- 沙箱搭建
|
|
34
|
+
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'ecosystem-panel-fixture-'));
|
|
35
|
+
const home = path.join(sandbox, 'home');
|
|
36
|
+
const profileDir = path.join(home, 'profiles', 'web');
|
|
37
|
+
const profileNm = path.join(profileDir, 'node_modules');
|
|
38
|
+
const toolsDir = path.join(sandbox, 'tools');
|
|
39
|
+
const mainNm = path.join(sandbox, 'hostnm');
|
|
40
|
+
const skillsDir = path.join(home, 'skills');
|
|
41
|
+
|
|
42
|
+
const write = (p, text) => {
|
|
43
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
44
|
+
fs.writeFileSync(p, text, 'utf8');
|
|
45
|
+
};
|
|
46
|
+
const writeJson = (p, obj) => write(p, JSON.stringify(obj, null, 2));
|
|
47
|
+
|
|
48
|
+
// ① 假 profile
|
|
49
|
+
writeJson(path.join(profileDir, 'package.json'), {
|
|
50
|
+
name: 'fixture-profile',
|
|
51
|
+
version: '0.0.0',
|
|
52
|
+
dependencies: {
|
|
53
|
+
'example-plugin': '1.0.0',
|
|
54
|
+
'patchable-plugin': '2.0.0',
|
|
55
|
+
'local-only-plugin': 'link:./local-only-plugin'
|
|
56
|
+
},
|
|
57
|
+
dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', 'example-plugin', 'patchable-plugin'] } }
|
|
58
|
+
});
|
|
59
|
+
// ② 实体:一个有 client 声明,一个带了补丁特征
|
|
60
|
+
writeJson(path.join(profileNm, 'example-plugin', 'package.json'), {
|
|
61
|
+
name: 'example-plugin', version: '1.0.0', dsh: { client: { inject: [], platform: 'web' } }
|
|
62
|
+
});
|
|
63
|
+
writeJson(path.join(profileNm, 'patchable-plugin', 'package.json'), { name: 'patchable-plugin', version: '2.0.0' });
|
|
64
|
+
write(path.join(profileNm, 'patchable-plugin', 'lib', 'client.js'), 'export const marker = "__PATCH_MARKER__";\n');
|
|
65
|
+
// ③ cordis.patch.yml(YAML 是 JSON 的超集 ⇒ 夹具用 flow 写法,真实 js-yaml 与夹具 stub 都能解析)
|
|
66
|
+
write(path.join(profileDir, 'cordis.patch.yml'),
|
|
67
|
+
'[{"insert":[{"id":"fixture-insert","name":"example-plugin"}]},{"id":"disabled-thing","insert":[{"id":"disabled-insert","name":"patchable-plugin"}],"disabled":true}]\n');
|
|
68
|
+
// ④ 假技能
|
|
69
|
+
write(path.join(skillsDir, 'good-skill', 'SKILL.md'), '---\n{"name":"good-skill","description":"夹具技能:frontmatter 完整"}\n---\n正文\n');
|
|
70
|
+
write(path.join(skillsDir, 'bad-skill', 'SKILL.md'), '---\n{"name":"bad-skill"}\n---\n正文\n');
|
|
71
|
+
// ⑤ js-yaml 占位实现(真实环境里它由宿主 profile 的依赖链提供;夹具只需要它解析上面那种 flow 写法)
|
|
72
|
+
writeJson(path.join(profileNm, 'js-yaml', 'package.json'), { name: 'js-yaml', version: '0.0.0-fixture', main: 'index.js' });
|
|
73
|
+
write(path.join(profileNm, 'js-yaml', 'index.js'), "module.exports = { load: (t) => JSON.parse(t) };\n");
|
|
74
|
+
// ⑥ 假宿主主包(target=global 的检查目标)
|
|
75
|
+
writeJson(path.join(mainNm, '@deepseek-ai', 'dsh', 'package.json'), { name: '@deepseek-ai/dsh', version: '1.2.3' });
|
|
76
|
+
write(path.join(mainNm, '@deepseek-ai', 'dsh-host-package', 'lib', 'index.js'), 'export const x = 1;\n');
|
|
77
|
+
// ⑦ 四个外部工具 stub(本插件只消费它们的输出,判定逻辑在它们自己那边)
|
|
78
|
+
write(path.join(toolsDir, 'check-decoupling.mjs'), `
|
|
79
|
+
export const VERSION = '9.9.9-fixture';
|
|
80
|
+
export function scanRoot(dir) {
|
|
81
|
+
return { error: null, results: [{
|
|
82
|
+
name: 'example-plugin', scope: 'tools', version: '1.0.0',
|
|
83
|
+
hardViolations: [],
|
|
84
|
+
softViolations: [{ module: '@deepseek-ai/dsh-some-package', file: 'lib/a.js', line: 3 }],
|
|
85
|
+
dependencies: [], clientInject: []
|
|
86
|
+
}] };
|
|
87
|
+
}
|
|
88
|
+
export function scanProfilePackages() { return { results: [] }; }
|
|
89
|
+
`);
|
|
90
|
+
write(path.join(toolsDir, 'sync-host-adapter.mjs'), `
|
|
91
|
+
export function verifySummary() { return { level: 'ok', note: 'fixture: 2/2 副本一致' }; }
|
|
92
|
+
`);
|
|
93
|
+
write(path.join(toolsDir, 'registry-audit.mjs'), `
|
|
94
|
+
export const REGISTRY_AUDIT_VERSION = '9.9.9-fixture';
|
|
95
|
+
export function auditRegistries({ now, startedAt }) {
|
|
96
|
+
return {
|
|
97
|
+
ok: true,
|
|
98
|
+
asOf: new Date(now).toISOString(),
|
|
99
|
+
counts: { actions: 1, modules: 1, jobs: 1, stateFiles: 1 },
|
|
100
|
+
byDomain: {}, problems: [], unavailable: [],
|
|
101
|
+
source: { libVersion: REGISTRY_AUDIT_VERSION, libMtime: null, startedAt, pendingRestart: false }
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
`);
|
|
105
|
+
write(path.join(toolsDir, 'upgrade-triage-cli.mjs'), `
|
|
106
|
+
let raw = '';
|
|
107
|
+
process.stdin.setEncoding('utf8');
|
|
108
|
+
process.stdin.on('data', (c) => { raw += c; });
|
|
109
|
+
process.stdin.on('end', () => {
|
|
110
|
+
let payload = {};
|
|
111
|
+
try { payload = JSON.parse(raw); } catch { /* 保持空 */ }
|
|
112
|
+
const items = (payload.packages ?? []).map((p) => ({
|
|
113
|
+
name: p.name,
|
|
114
|
+
tier: p.name === 'patchable-plugin' ? 'yellow' : 'green',
|
|
115
|
+
tierLabel: p.name === 'patchable-plugin' ? '夹具:可升(有大版本差)' : '夹具:最新',
|
|
116
|
+
available: true,
|
|
117
|
+
reasons: ['夹具判定:版本比较结果'],
|
|
118
|
+
local: p.localVersion, target: p.targetVersion, targetSource: 'fixture'
|
|
119
|
+
}));
|
|
120
|
+
process.stdout.write(JSON.stringify({
|
|
121
|
+
ok: true, items, tierSource: 'fixture-triage', cliVersion: '9.9.9-fixture',
|
|
122
|
+
triageVersion: '9.9.9-fixture', cacheOnly: false, inputGaps: [], elapsedMs: 1
|
|
123
|
+
}));
|
|
124
|
+
});
|
|
125
|
+
`);
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------- 载入被测模块
|
|
128
|
+
// ⚠️ 必须在 import 之前设好数据根:lib/state.js 的 YAML 依赖是在**模块加载期**经
|
|
129
|
+
// createRequire(<数据根>/profiles/<profile>/package.json) 解析的。
|
|
130
|
+
process.env.DSH_ECOSYSTEM_PANEL_HOME = home;
|
|
131
|
+
const stateMod = await import(pathToFileURL(path.join(ROOT, 'lib', 'state.js')).href);
|
|
132
|
+
ok('YAML 依赖在夹具数据根上解析成功(模块加载期)', stateMod.YAML_AVAILABLE === true, String(stateMod.YAML_ERROR ?? ''));
|
|
133
|
+
|
|
134
|
+
const baseCfg = {
|
|
135
|
+
home,
|
|
136
|
+
profile: 'web',
|
|
137
|
+
toolsDir,
|
|
138
|
+
mainNm,
|
|
139
|
+
// 故意指向一个不可达端口:验证"抓不到 boot 清单"时是显式未知,而不是被当成"未加载"
|
|
140
|
+
webUrl: 'http://127.0.0.1:1',
|
|
141
|
+
patchManifest: [
|
|
142
|
+
{
|
|
143
|
+
id: 'fixture-healthy', title: '夹具:健康补丁', script: 'patch-fixture-healthy.mjs',
|
|
144
|
+
checks: [{ pkg: 'patchable-plugin', rel: 'lib/client.js', contains: ['__PATCH_MARKER__'] }]
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
id: 'fixture-missing-feature', title: '夹具:特征丢失', script: 'patch-fixture-lost.mjs',
|
|
148
|
+
checks: [{ pkg: 'patchable-plugin', rel: 'lib/client.js', contains: ['__NEVER_THERE__'] }]
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
id: 'fixture-global-target', title: '夹具:宿主主包内目标', script: 'patch-fixture-global.mjs',
|
|
152
|
+
checks: [{ target: 'global', pkg: '@deepseek-ai/dsh-nowhere', rel: 'lib/index.js', contains: ['x'] }]
|
|
153
|
+
}
|
|
154
|
+
],
|
|
155
|
+
upgradeRules: { 'example-plugin': { verdict: 'upgradable', label: '夹具:可升', note: '夹具规则文案' } },
|
|
156
|
+
labels: { 'example-plugin': '夹具插件(有 client 声明)' }
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------- [1] 全配置态:六类齐活
|
|
160
|
+
console.log('\n=== [1] 全配置态:六类体检都能跑出结果 ===');
|
|
161
|
+
const s1 = await stateMod.buildState({ config: baseCfg });
|
|
162
|
+
ok('① bundles 三条(core 一条 + 有实体两条)', s1.groups.bundles.length === 3, JSON.stringify(s1.groups.bundles.map((b) => b.name)));
|
|
163
|
+
const coreRow = s1.groups.bundles.find((b) => b.isCore);
|
|
164
|
+
ok('① core 包判绿(随主包发行)', coreRow?.status === 'green', coreRow?.status);
|
|
165
|
+
const exRow = s1.groups.bundles.find((b) => b.name === 'example-plugin');
|
|
166
|
+
ok('① 抓不到 boot 清单时 bootLoaded = null(未知,不当作未加载)', exRow?.bootLoaded === null);
|
|
167
|
+
ok('① 未知加载状态 → 黄色并说明原因', exRow?.status === 'yellow' && /加载状态未知/.test(exRow.problems.join(' ')), exRow?.status);
|
|
168
|
+
ok('① boot 抓取失败被如实记录', s1.meta.bootEntries === null && typeof s1.meta.bootError === 'string' && s1.meta.bootError.length > 0, s1.meta.bootError);
|
|
169
|
+
ok('① 配置字典里的简介被用上', exRow?.description === '夹具插件(有 client 声明)', exRow?.description);
|
|
170
|
+
|
|
171
|
+
ok('② inserts 两条(含一条 disabled)', s1.groups.inserts.length === 2, JSON.stringify(s1.groups.inserts.map((i) => i.id)));
|
|
172
|
+
ok('② 实体存在的 insert 判绿', s1.groups.inserts.find((i) => i.id === 'fixture-insert')?.status === 'green');
|
|
173
|
+
ok('② 被 disabled 的 insert 判黄且说明', s1.groups.inserts.find((i) => i.id === 'disabled-insert')?.status === 'yellow');
|
|
174
|
+
ok('② 被整个禁用的 id patch 被列出', Array.isArray(s1.groups.disabledPatchIds) && s1.groups.disabledPatchIds.includes('disabled-thing'), JSON.stringify(s1.groups.disabledPatchIds));
|
|
175
|
+
ok('② patch.yml 解析无错', s1.meta.patchYamlError === null, String(s1.meta.patchYamlError));
|
|
176
|
+
|
|
177
|
+
ok('③ 补丁清单条目数正确', s1.groups.patches.length === 3, String(s1.groups.patches.length));
|
|
178
|
+
ok('③ 特征齐全 → 绿', s1.groups.patches.find((p) => p.id === 'fixture-healthy')?.status === 'green');
|
|
179
|
+
const lost = s1.groups.patches.find((p) => p.id === 'fixture-missing-feature');
|
|
180
|
+
ok('③ 特征丢失 → 黄 + 指出缺哪个特征', lost?.status === 'yellow' && /缺特征/.test(lost.problems.join(' ')), lost?.problems?.[0]);
|
|
181
|
+
ok('③ 目标文件缺失 → 红(整体可能已卸载/未装)',
|
|
182
|
+
s1.groups.patches.find((p) => p.id === 'fixture-global-target')?.status === 'red',
|
|
183
|
+
JSON.stringify(s1.groups.patches.find((p) => p.id === 'fixture-global-target')?.problems));
|
|
184
|
+
|
|
185
|
+
ok('④ 技能两条都读到', s1.groups.skills.length === 2, String(s1.groups.skills.length));
|
|
186
|
+
ok('④ frontmatter 完整 → 绿', s1.groups.skills.find((s) => s.name === 'good-skill')?.status === 'green');
|
|
187
|
+
const badSkill = s1.groups.skills.find((s) => s.name === 'bad-skill');
|
|
188
|
+
ok('④ 缺 description → 黄 + 指出缺什么', badSkill?.status === 'yellow' && /description/.test(badSkill.problems.join(' ')), badSkill?.problems?.[0]);
|
|
189
|
+
|
|
190
|
+
ok('⑤ 档位来自外部只读 CLI(不是面板自算)', s1.upgradesMeta?.tier?.ok === true && s1.upgradesMeta.tier.tierSource === 'fixture-triage',
|
|
191
|
+
String(s1.upgradesMeta?.tier?.tierSource));
|
|
192
|
+
const upRow = s1.groups.upgrades.find((u) => u.name === 'example-plugin');
|
|
193
|
+
ok('⑤ 插件行状态 = CLI 给的档位', upRow?.status === 'green' && upRow?.tier === 'green', upRow?.status);
|
|
194
|
+
ok('⑤ 另一包拿到 CLI 的黄色档位', s1.groups.upgrades.find((u) => u.name === 'patchable-plugin')?.status === 'yellow');
|
|
195
|
+
ok('⑤ 规则表文案只作注解、不决定颜色',
|
|
196
|
+
(upRow?.verdictLabel ?? '').includes('夹具') || (upRow?.problems ?? []).some((p) => p.includes('规则表')),
|
|
197
|
+
JSON.stringify({ label: upRow?.verdictLabel, problems: upRow?.problems }));
|
|
198
|
+
ok('⑤ 补丁关联被反查出来', upRow?.patchCount === 0 && s1.groups.upgrades.find((u) => u.name === 'patchable-plugin')?.patchCount === 2,
|
|
199
|
+
String(s1.groups.upgrades.find((u) => u.name === 'patchable-plugin')?.patchCount));
|
|
200
|
+
|
|
201
|
+
ok('⑥ 解耦脚本被复用(不是复制判定)', s1.decouplingMeta?.scriptVersion === '9.9.9-fixture' && s1.decouplingMeta.packages === 1,
|
|
202
|
+
JSON.stringify({ v: s1.decouplingMeta?.scriptVersion, n: s1.decouplingMeta?.packages }));
|
|
203
|
+
ok('⑥ 解耦明细进面板行', s1.groups.decoupling[0]?.name === 'example-plugin' && s1.groups.decoupling[0]?.r1Soft === 1);
|
|
204
|
+
ok('⑥ 档位输入拿到了 R5 结论(可选项配置了就生效)', s1.meta.config['scripts.hostAdapterSync'].value !== null);
|
|
205
|
+
|
|
206
|
+
ok('⑦ 跨表准入校验器分组状态 = ok', s1.registryMeta?.status === 'ok' && typeof s1.registryMeta.asOf === 'string', String(s1.registryMeta?.asOf));
|
|
207
|
+
ok('⑦ 校验器版本与来源如实回传', s1.registryMeta?.libVersion === '9.9.9-fixture' && /registry-audit\.mjs$/.test(String(s1.registryMeta?.lib)),
|
|
208
|
+
String(s1.registryMeta?.lib));
|
|
209
|
+
|
|
210
|
+
ok('汇总计数与明细自洽', s1.summary.total === s1.groups.bundles.length + s1.groups.inserts.length + s1.groups.patches.length + s1.groups.skills.length + s1.groups.decoupling.length,
|
|
211
|
+
JSON.stringify(s1.summary));
|
|
212
|
+
ok('面板版本从自身 package.json 读(不写死)', /^\d+\.\d+\.\d+$/.test(String(s1.meta.panelVersion)), String(s1.meta.panelVersion));
|
|
213
|
+
ok('配置来源自证里 toolsDir 标为 cli 层', s1.meta.config.toolsDir.from === 'cli', String(s1.meta.config.toolsDir.from));
|
|
214
|
+
ok('配置期无错误', Array.isArray(s1.meta.configErrors) && s1.meta.configErrors.length === 0, JSON.stringify(s1.meta.configErrors));
|
|
215
|
+
|
|
216
|
+
// ---------------------------------------------------------------- [2] 缺数据态:必须显式降级
|
|
217
|
+
console.log('\n=== [2] 缺数据态:不可探测 + 原因(绝不 0 / 假绿)===');
|
|
218
|
+
const s2 = await stateMod.buildState({ config: { home, webUrl: 'http://127.0.0.1:1' } });
|
|
219
|
+
ok('② patches 组给出「未配置」提示项(不是 0 条)', s2.groups.patches.length === 1 && s2.groups.patches[0].status === 'yellow'
|
|
220
|
+
&& /未配置补丁清单/.test(s2.groups.patches[0].problems.join(' ')), JSON.stringify(s2.groups.patches[0].problems));
|
|
221
|
+
ok('③ 该项不计入"健康"(title 明写未配置)', s2.groups.patches[0].title === '补丁清单未配置');
|
|
222
|
+
ok('⑥ 解耦组给出"未配置脚本"降级(黄色 + 原因)',
|
|
223
|
+
s2.groups.decoupling.length === 1 && s2.groups.decoupling[0].status === 'yellow'
|
|
224
|
+
&& /未配置解耦检查脚本/.test(s2.groups.decoupling[0].problems.join(' ')), JSON.stringify(s2.groups.decoupling[0].problems));
|
|
225
|
+
ok('⑦ 跨表准入 = unavailable(**不是** 0/0/0 绿)', s2.registryMeta.status === 'unavailable'
|
|
226
|
+
&& /未配置跨表准入校验器/.test(String(s2.registryMeta.reason)), String(s2.registryMeta.reason));
|
|
227
|
+
ok('⑦ 面板行也标黄且写清"判定不可用"', s2.groups.registry[0].status === 'yellow' && /判定不可用/.test(s2.groups.registry[0].problems.join(' ')));
|
|
228
|
+
ok('⑤ 档位 CLI 未配置 ⇒ 档位不可用(未渲染颜色)',
|
|
229
|
+
s2.upgradesMeta.tier.ok === false && /未配置档位判定 CLI/.test(String(s2.upgradesMeta.tier.reason)), String(s2.upgradesMeta.tier.reason));
|
|
230
|
+
ok('⑤ 所有插件行 status = unknown(绝不假绿)',
|
|
231
|
+
s2.groups.upgrades.filter((u) => u.name !== '@deepseek-ai/dsh').every((u) => u.status === 'unknown'),
|
|
232
|
+
JSON.stringify(s2.groups.upgrades.map((u) => [u.name, u.status])));
|
|
233
|
+
ok('⑤ 每行都带"档位不可用"的原因', s2.groups.upgrades.filter((u) => u.name !== '@deepseek-ai/dsh')
|
|
234
|
+
.every((u) => (u.problems ?? []).some((p) => /档位不可用/.test(p))));
|
|
235
|
+
ok('meta.toolsDir = null(不编造工具目录)', s2.meta.toolsDir === null, String(s2.meta.toolsDir));
|
|
236
|
+
ok('② insert 仍能判(只依赖数据根,不依赖工具目录)', s2.groups.inserts.length === 2);
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------- [3] 停用类目
|
|
239
|
+
console.log('\n=== [3] 配置停用类目:显式呈现、不计入汇总 ===');
|
|
240
|
+
const s3 = await stateMod.buildState({ config: { ...baseCfg, disabledGroups: ['patches'] } });
|
|
241
|
+
ok('停用组显示为「已按配置停用」提示项', s3.groups.patches.length === 1 && /已按配置停用/.test(s3.groups.patches[0].name), s3.groups.patches[0]?.name);
|
|
242
|
+
ok('停用组不计入 summary.total', s3.summary.total === s1.summary.total - 3, `${s3.summary.total} vs ${s1.summary.total}`);
|
|
243
|
+
ok('停用组仍如实报出配置值', JSON.stringify(s3.meta.disabledGroups) === '["patches"]', JSON.stringify(s3.meta.disabledGroups));
|
|
244
|
+
|
|
245
|
+
// ---------------------------------------------------------------- [4] 沙箱清理
|
|
246
|
+
try { fs.rmSync(sandbox, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
247
|
+
ok('夹具沙箱已清理', !fs.existsSync(sandbox));
|
|
248
|
+
|
|
249
|
+
console.log(`\n=== 结果:通过 ${pass} / 失败 ${fail} ===`);
|
|
250
|
+
console.log(fail === 0 ? 'FIXTURE PASSED' : 'FIXTURE FAILED');
|
|
251
|
+
process.exitCode = fail === 0 ? 0 : 1;
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/verify-source.mjs —— 发布前静态验证(**必跑**)
|
|
3
|
+
//
|
|
4
|
+
// 三件事:
|
|
5
|
+
// ① 语法:对包内全部 `.mjs` / `.js` 跑 `node --check`
|
|
6
|
+
// ② 配置层行为:路径解析优先级、清单加载与坏输入降级、停用类目、占位符展开(全部在系统临时目录里做,绝不写仓库)
|
|
7
|
+
// ③ 脱敏扫描:本机路径 / 用户名 / 设备名 / 个人称呼 / 内部专有名 / 卡与会话 id 形态 / 凭证形态
|
|
8
|
+
//
|
|
9
|
+
// ⚠️ 本文件**不写任何真实机器名、用户名、本机路径、个人称呼或内部专有名**(否则等于把要藏的东西
|
|
10
|
+
// 写进要发布的仓)。规则表 = 「通用形态规则(永久生效)」+「环境标识规则(**由发布环境注入**)」。
|
|
11
|
+
// · 通用形态:用户目录形态、盘符绝对路径、卡 id 形态、会话 id 形态、凭证形态 —— 与具体机器无关,永远生效。
|
|
12
|
+
// · 环境标识:机器名 / 用户名 / 本机目录名 / 同步产品名 / 内部专有名 / 个人称呼 —— **只能靠注入**。
|
|
13
|
+
// 其中「内部专有名」「个人称呼」形态上无法枚举,**不注入就没有这条规则**,
|
|
14
|
+
// 所以发布前务必把环境变量带全(下面会打印本次到底加载了几条)。
|
|
15
|
+
// 注入方式(逗号或分号或竖线分隔,大小写不敏感):
|
|
16
|
+
// BUILD_MACHINE_NAMES 例:<主机名1>,<主机名2>
|
|
17
|
+
// BUILD_USER_NAMES 例:<用户名1>,<用户名2>
|
|
18
|
+
// BUILD_LAYOUT_ROOTS 例:<工作区目录名>,<测试根目录名>
|
|
19
|
+
// BUILD_SYNC_PRODUCTS 例:<同步产品目录名>
|
|
20
|
+
// BUILD_INTERNAL_TERMS 例:<内部项目代号>,<内部体系名> ← 形态上无法枚举,必须注入
|
|
21
|
+
// BUILD_PERSONAL_TERMS 例:<私人称呼1>,<私人称呼2> ← **本文件里一个字都不写**
|
|
22
|
+
// 另外:③ 段末尾会对**本文件自身**再跑一遍「注入规则」,所以「扫描器自己没有夹带私人称呼与
|
|
23
|
+
// 内部专有名」是一条**可复跑的断言**,而不是一句人工声明。
|
|
24
|
+
//
|
|
25
|
+
// 用法:
|
|
26
|
+
// node scripts/verify-source.mjs [包根] # 包根默认 = 本文件的上一级
|
|
27
|
+
// 退出码:0 = 全部通过;1 = 有失败项。
|
|
28
|
+
|
|
29
|
+
import fs from 'node:fs';
|
|
30
|
+
import os from 'node:os';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
import { spawnSync } from 'node:child_process';
|
|
33
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
34
|
+
|
|
35
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
36
|
+
const ROOT = path.resolve(process.argv[2] ?? path.join(HERE, '..'));
|
|
37
|
+
const SELF_REL = 'scripts/verify-source.mjs';
|
|
38
|
+
|
|
39
|
+
let fail = 0;
|
|
40
|
+
const check = (name, ok, detail = '') => {
|
|
41
|
+
console.log((ok ? '[OK] ' : '[FAIL]') + ' ' + name + (detail ? ' :: ' + detail : ''));
|
|
42
|
+
if (!ok) fail += 1;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------- ① 语法
|
|
46
|
+
|
|
47
|
+
const codeFiles = [];
|
|
48
|
+
const walk = (dir) => {
|
|
49
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
50
|
+
if (entry === 'node_modules' || entry === '.git' || entry === '.backups') continue;
|
|
51
|
+
const full = path.join(dir, entry);
|
|
52
|
+
const st = fs.statSync(full);
|
|
53
|
+
if (st.isDirectory()) walk(full);
|
|
54
|
+
else if (['.mjs', '.js', '.cjs'].includes(path.extname(entry))) codeFiles.push(full);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
walk(ROOT);
|
|
58
|
+
for (const file of codeFiles) {
|
|
59
|
+
const rel = path.relative(ROOT, file).replace(/\\/g, '/');
|
|
60
|
+
const res = spawnSync(process.execPath, ['--check', file], { encoding: 'utf8' });
|
|
61
|
+
check('syntax ' + rel, res.status === 0, (res.stderr || '').split('\n')[0] ?? '');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------- ② 配置层行为
|
|
65
|
+
|
|
66
|
+
const cfg = await import(pathToFileURL(path.join(ROOT, 'lib', 'config.js')).href);
|
|
67
|
+
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'ecosystem-panel-verify-'));
|
|
68
|
+
const fakeHome = path.join(sandbox, 'home');
|
|
69
|
+
fs.mkdirSync(path.join(fakeHome, 'profiles', 'web'), { recursive: true });
|
|
70
|
+
const cleanEnv = {}; // 与环境无关的空环境(配置层接受 env 入参,无需动 process.env)
|
|
71
|
+
|
|
72
|
+
// 默认值
|
|
73
|
+
const d0 = cfg.resolveConfig({}, cleanEnv);
|
|
74
|
+
check('默认数据根 = <home>/.dsh', d0.home === path.join(os.homedir(), '.dsh'), d0.home);
|
|
75
|
+
check('默认 profile = web 且拼出 profile 目录',
|
|
76
|
+
d0.profileDir === path.join(os.homedir(), '.dsh', 'profiles', 'web'), d0.profileDir);
|
|
77
|
+
check('DSH_HOME 覆盖生效', cfg.resolveConfig({}, { DSH_HOME: fakeHome }).home === path.resolve(fakeHome));
|
|
78
|
+
check('DSH_ECOSYSTEM_PANEL_HOME 优先于 DSH_HOME',
|
|
79
|
+
cfg.resolveConfig({}, { DSH_HOME: sandbox, DSH_ECOSYSTEM_PANEL_HOME: fakeHome }).home === path.resolve(fakeHome));
|
|
80
|
+
check('显式 cli.home 优先级最高',
|
|
81
|
+
cfg.resolveConfig({ home: path.join(sandbox, 'x') }, { DSH_ECOSYSTEM_PANEL_HOME: fakeHome }).home === path.join(sandbox, 'x'));
|
|
82
|
+
check('cli.profile 覆盖 profile 名',
|
|
83
|
+
cfg.resolveConfig({ home: fakeHome, profile: 'alt' }, cleanEnv).profileDir === path.join(fakeHome, 'profiles', 'alt'));
|
|
84
|
+
|
|
85
|
+
// 外部工具:**不自动猜**
|
|
86
|
+
check('未配置时 toolsDir = null(不猜路径)', d0.toolsDir === null, String(d0.toolsDir));
|
|
87
|
+
check('未配置时档位 CLI = null 且来源写明不可用',
|
|
88
|
+
d0.scripts.triageCli.path === null && /unavailable/.test(String(d0.scripts.triageCli.from)),
|
|
89
|
+
String(d0.scripts.triageCli.from));
|
|
90
|
+
const withTools = cfg.resolveConfig({ home: fakeHome, toolsDir: path.join(sandbox, 'tools') }, cleanEnv);
|
|
91
|
+
check('给了 toolsDir 后按默认相对名派生脚本路径',
|
|
92
|
+
withTools.scripts.triageCli.path === path.join(sandbox, 'tools', 'upgrade-triage-cli.mjs'), withTools.scripts.triageCli.path);
|
|
93
|
+
check('env 显式指定脚本路径优先于 toolsDir 派生',
|
|
94
|
+
cfg.resolveConfig({ home: fakeHome, toolsDir: sandbox }, { DSH_ECOSYSTEM_PANEL_TRIAGE_CLI: path.join(sandbox, 'my-cli.mjs') })
|
|
95
|
+
.scripts.triageCli.path === path.join(sandbox, 'my-cli.mjs'));
|
|
96
|
+
check('显式 cli 参数优先级最高',
|
|
97
|
+
cfg.resolveConfig({ toolsDir: sandbox, scripts: { triageCli: path.join(sandbox, 'cli2.mjs') } }, cleanEnv)
|
|
98
|
+
.scripts.triageCli.path === path.join(sandbox, 'cli2.mjs'));
|
|
99
|
+
|
|
100
|
+
// 主包 node_modules:探测或显式
|
|
101
|
+
const withMain = cfg.resolveConfig({ home: fakeHome, mainNm: path.join(sandbox, 'pkgroot', 'node_modules') }, cleanEnv);
|
|
102
|
+
check('显式 mainNm 生效且 mainPkgDir = 其上一级',
|
|
103
|
+
withMain.mainNm === path.join(sandbox, 'pkgroot', 'node_modules') && withMain.mainPkgDir === path.join(sandbox, 'pkgroot'),
|
|
104
|
+
String(withMain.mainPkgDir));
|
|
105
|
+
check('未探测到主包布局时 mainNm = null(不编造)', cfg.resolveConfig({ home: fakeHome }, {}).mainNm === null);
|
|
106
|
+
// 自动探测:按「全局 npm 布局」探测;目录不存在则判不可用(并写明原因)
|
|
107
|
+
const roaming = path.join(sandbox, 'roaming');
|
|
108
|
+
const probedNm = path.join(roaming, 'npm', 'node_modules', '@deepseek-ai', 'dsh', 'node_modules');
|
|
109
|
+
fs.mkdirSync(probedNm, { recursive: true });
|
|
110
|
+
check('全局 npm 布局存在时自动探测命中(含 for 说明)',
|
|
111
|
+
cfg.resolveConfig({ home: fakeHome }, { APPDATA: roaming }).mainNm === probedNm,
|
|
112
|
+
String(cfg.resolveConfig({ home: fakeHome }, { APPDATA: roaming }).sources.mainNm.from));
|
|
113
|
+
check('APPDATA 在但布局不在 ⇒ 判不可用且写明原因',
|
|
114
|
+
cfg.resolveConfig({ home: fakeHome }, { APPDATA: path.join(sandbox, 'nope') }).mainNm === null
|
|
115
|
+
&& /unavailable/.test(String(cfg.resolveConfig({ home: fakeHome }, { APPDATA: path.join(sandbox, 'nope') }).sources.mainNm.from)));
|
|
116
|
+
|
|
117
|
+
// 清单文件:正常 / 坏 JSON / 内联优先 / 占位符展开
|
|
118
|
+
const manifestFile = path.join(sandbox, 'patches.json');
|
|
119
|
+
fs.writeFileSync(manifestFile, JSON.stringify([{ id: 'p1', checks: [{ pkg: 'example-plugin', rel: 'lib/client.js' }] }]), 'utf8');
|
|
120
|
+
const withManifest = cfg.resolveConfig({ home: fakeHome, patchManifestFile: manifestFile }, cleanEnv);
|
|
121
|
+
check('清单文件被读入(数量正确)', Array.isArray(withManifest.patchManifest) && withManifest.patchManifest.length === 1);
|
|
122
|
+
const badFile = path.join(sandbox, 'bad.json');
|
|
123
|
+
fs.writeFileSync(badFile, '{ not json', 'utf8');
|
|
124
|
+
const withBad = cfg.resolveConfig({ home: fakeHome, patchManifestFile: badFile }, cleanEnv);
|
|
125
|
+
check('坏 JSON 不抛错、回落空表且记入 errors',
|
|
126
|
+
withBad.patchManifest.length === 0 && withBad.errors.length === 1, JSON.stringify(withBad.errors).slice(0, 120));
|
|
127
|
+
check('内联 patchManifest 优先于文件',
|
|
128
|
+
cfg.resolveConfig({ home: fakeHome, patchManifest: [{ id: 'inline' }], patchManifestFile: manifestFile }, cleanEnv)
|
|
129
|
+
.patchManifest[0].id === 'inline');
|
|
130
|
+
const expanded = cfg.resolveConfig({
|
|
131
|
+
home: fakeHome,
|
|
132
|
+
patchManifest: [{ id: 'p2', special: { kind: 'no-sidecar-files', dir: '${HOME}/data' } }]
|
|
133
|
+
}, cleanEnv);
|
|
134
|
+
check('清单里的 ${HOME} 占位符被展开',
|
|
135
|
+
path.normalize(expanded.patchManifest[0].special.dir) === path.join(path.resolve(fakeHome), 'data'),
|
|
136
|
+
expanded.patchManifest[0].special.dir);
|
|
137
|
+
|
|
138
|
+
// 混合资产:core 白名单 / 停用类目 / 文案 / 来源自证
|
|
139
|
+
check('core 白名单默认 3 条官方内置包', d0.coreBundles.length === 3, d0.coreBundles.join(','));
|
|
140
|
+
check('core 白名单可追加且去重',
|
|
141
|
+
cfg.resolveConfig({ coreBundles: ['x', '@deepseek-ai/dsh-base'] }, cleanEnv).coreBundles.length === 4);
|
|
142
|
+
const disabled = cfg.resolveConfig({}, { DSH_ECOSYSTEM_PANEL_DISABLED_GROUPS: 'patches,registry' });
|
|
143
|
+
check('停用类目按逗号解析且可查询', disabled.isGroupDisabled('patches') && disabled.isGroupDisabled('registry') && !disabled.isGroupDisabled('skills'));
|
|
144
|
+
const withText = cfg.resolveConfig({ text: { title: 'X' } }, cleanEnv);
|
|
145
|
+
check('文案覆盖被接受', withText.text.title === 'X', JSON.stringify(withText.text));
|
|
146
|
+
check('来源自证:覆盖了脚本四项与主要路径',
|
|
147
|
+
Object.values(cfg.describeResolution(withTools)).every((v) => typeof v.from === 'string' && v.from.length > 0)
|
|
148
|
+
&& ['scripts.decoupling', 'scripts.triageCli', 'scripts.registryAudit', 'home', 'toolsDir'].every((k) => k in cfg.describeResolution(withTools)));
|
|
149
|
+
check('describeResolution 不泄露仓库自身的绝对路径',
|
|
150
|
+
!JSON.stringify(cfg.describeResolution(withTools)).includes(ROOT));
|
|
151
|
+
|
|
152
|
+
// getConfig 单例与重置
|
|
153
|
+
cfg.resetConfig();
|
|
154
|
+
cfg.getConfig({ home: fakeHome });
|
|
155
|
+
cfg.setConfigOverride({ profile: 'web2' });
|
|
156
|
+
check('setConfigOverride 后 getConfig 反映新值(且不污染未覆盖键)',
|
|
157
|
+
cfg.getConfig().profile === 'web2'
|
|
158
|
+
&& cfg.getConfig().home === cfg.resolveConfig({ profile: 'web2' }).home);
|
|
159
|
+
cfg.resetConfig();
|
|
160
|
+
check('resetConfig 后回到默认口径(与 resolveConfig({}) 一致)', cfg.getConfig().home === cfg.resolveConfig({}).home);
|
|
161
|
+
try { fs.rmSync(sandbox, { recursive: true, force: true }) } catch { /* ignore */ }
|
|
162
|
+
check('配置层验证沙箱已清理', !fs.existsSync(sandbox));
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------- ③ 脱敏扫描
|
|
165
|
+
|
|
166
|
+
// 注入值分隔符:逗号 / 分号 / 竖线都认。
|
|
167
|
+
// ⚠️ 竖线也必须认 —— 注入值最终是「多值拼成一条正则」(join('|')),所以「A|B」与「A,B」语义**完全相同**;
|
|
168
|
+
// 若不认竖线,用户按正则习惯写「A|B」会被 esc() 转义成一个字面量 ⇒ 规则静默哑火、扫描假装通过。
|
|
169
|
+
const envList = (name) => (process.env[name] ?? '')
|
|
170
|
+
.split(/[,;|]/).map((s) => s.trim()).filter((s) => s.length >= 2)
|
|
171
|
+
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
172
|
+
{
|
|
173
|
+
process.env.__BUILD_SPLIT_PROBE = 'aa,bb;cc|dd'
|
|
174
|
+
const sp = envList('__BUILD_SPLIT_PROBE')
|
|
175
|
+
delete process.env.__BUILD_SPLIT_PROBE
|
|
176
|
+
check('注入值分隔符:逗号 / 分号 / 竖线都认', sp.join(',') === 'aa,bb,cc,dd', sp.join(','))
|
|
177
|
+
}
|
|
178
|
+
const EXTRA_MACHINES = envList('BUILD_MACHINE_NAMES')
|
|
179
|
+
const EXTRA_USERS = envList('BUILD_USER_NAMES')
|
|
180
|
+
const EXTRA_ROOTS = envList('BUILD_LAYOUT_ROOTS')
|
|
181
|
+
const EXTRA_SYNC = envList('BUILD_SYNC_PRODUCTS')
|
|
182
|
+
// 内部专有名 / 项目代号:形态上无法枚举(既不是 id、也不是路径),**只能由发布环境注入**。
|
|
183
|
+
const EXTRA_TERMS = envList('BUILD_INTERNAL_TERMS')
|
|
184
|
+
// 个人称呼(机主 / 助手自称那一类):**绝不写在本文件里** —— 本文件会进公开仓,
|
|
185
|
+
// 把私人称谓明文写进扫描器,等于换个地方把它公开。一律由发布环境注入:
|
|
186
|
+
// BUILD_PERSONAL_TERMS=<称呼1>,<称呼2>,…
|
|
187
|
+
const EXTRA_PERSONAL = envList('BUILD_PERSONAL_TERMS')
|
|
188
|
+
|
|
189
|
+
// 通用形态规则:与具体机器无关,永远生效
|
|
190
|
+
const PATTERNS = [
|
|
191
|
+
[/[\\/]Users[\\/](?!<|\$\{|%|<user>)[A-Za-z0-9._-]{2,}/, 'user profile dir'],
|
|
192
|
+
[/(?:^|[^A-Za-z0-9])[A-Za-z]:[\\/](?!<|\$\{|%|<path>)[^\s'"`<>|?*]{2,}/, 'absolute windows path'],
|
|
193
|
+
[/\b[0-9a-f]{8}\b/, 'card id shape (8-hex)'],
|
|
194
|
+
[/\bsession-[0-9a-f]{8}\b/, 'session id shape'],
|
|
195
|
+
[/\bsk-[A-Za-z0-9]{8,}/, 'api key'],
|
|
196
|
+
]
|
|
197
|
+
const GENERIC_COUNT = PATTERNS.length
|
|
198
|
+
// 环境标识规则:全部由 BUILD_* 注入(未注入 = 没有这条规则,通用形态规则兜底);
|
|
199
|
+
// 单独存一份 INJECTED_PATTERNS,③ 段末尾要用它给**本文件自身**做自检。
|
|
200
|
+
const INJECTED_PATTERNS = []
|
|
201
|
+
if (EXTRA_SYNC.length) INJECTED_PATTERNS.push([new RegExp(EXTRA_SYNC.map(esc).join('|'), 'i'), 'sync product (injected)'])
|
|
202
|
+
if (EXTRA_TERMS.length) INJECTED_PATTERNS.push([new RegExp(EXTRA_TERMS.map(esc).join('|'), 'i'), 'internal proper name (injected)'])
|
|
203
|
+
if (EXTRA_PERSONAL.length) INJECTED_PATTERNS.push([new RegExp(EXTRA_PERSONAL.map(esc).join('|'), 'i'), 'personal address (injected)'])
|
|
204
|
+
if (EXTRA_MACHINES.length) INJECTED_PATTERNS.push([new RegExp(EXTRA_MACHINES.map(esc).join('|'), 'i'), 'machine name (injected)'])
|
|
205
|
+
if (EXTRA_USERS.length) INJECTED_PATTERNS.push([new RegExp(EXTRA_USERS.map(esc).join('|'), 'i'), 'user name (injected)'])
|
|
206
|
+
if (EXTRA_ROOTS.length) INJECTED_PATTERNS.push([new RegExp(EXTRA_ROOTS.map(esc).join('|'), 'i'), 'local layout root (injected)'])
|
|
207
|
+
PATTERNS.unshift(...INJECTED_PATTERNS)
|
|
208
|
+
const injected = EXTRA_MACHINES.length + EXTRA_USERS.length + EXTRA_ROOTS.length + EXTRA_SYNC.length
|
|
209
|
+
+ EXTRA_TERMS.length + EXTRA_PERSONAL.length
|
|
210
|
+
console.log('[i] 脱敏规则:通用 ' + GENERIC_COUNT + ' 条 + 注入 ' + injected + ' 条'
|
|
211
|
+
+ '(machine=' + EXTRA_MACHINES.length + ' user=' + EXTRA_USERS.length
|
|
212
|
+
+ ' layout=' + EXTRA_ROOTS.length + ' sync=' + EXTRA_SYNC.length
|
|
213
|
+
+ ' terms=' + EXTRA_TERMS.length + ' personal=' + EXTRA_PERSONAL.length + ')'
|
|
214
|
+
+ (injected
|
|
215
|
+
? ''
|
|
216
|
+
: ' ← 未注入 BUILD_* 变量(MACHINE_NAMES / USER_NAMES / LAYOUT_ROOTS / SYNC_PRODUCTS / INTERNAL_TERMS'
|
|
217
|
+
+ ' / PERSONAL_TERMS),仅通用形态规则生效'))
|
|
218
|
+
|
|
219
|
+
const scanTargets = [...codeFiles]
|
|
220
|
+
for (const rel of ['README.md', 'CHANGELOG.md', 'LICENSE', 'docs/RELEASING.md', '.gitignore', 'package.json']) {
|
|
221
|
+
const full = path.join(ROOT, rel)
|
|
222
|
+
if (fs.existsSync(full) && !scanTargets.includes(full)) scanTargets.push(full)
|
|
223
|
+
}
|
|
224
|
+
for (const dir of ['examples', 'docs']) {
|
|
225
|
+
const full = path.join(ROOT, dir)
|
|
226
|
+
if (!fs.existsSync(full)) continue
|
|
227
|
+
const stack = [full]
|
|
228
|
+
while (stack.length) {
|
|
229
|
+
const d = stack.pop()
|
|
230
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
231
|
+
const f = path.join(d, e.name)
|
|
232
|
+
if (e.isDirectory()) stack.push(f)
|
|
233
|
+
else if (!scanTargets.includes(f)) scanTargets.push(f)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
let scannedLines = 0
|
|
238
|
+
let scanHits = 0
|
|
239
|
+
for (const file of scanTargets) {
|
|
240
|
+
const rel = path.relative(ROOT, file).replace(/\\/g, '/')
|
|
241
|
+
// 本文件自身定义检测规则(规则文本里必然出现 "Users"、盘符这类**形态样例**),故不参与**通用**规则扫描;
|
|
242
|
+
// 但它必须过**注入**规则 —— 见循环之后的自检段。
|
|
243
|
+
if (rel === SELF_REL) continue
|
|
244
|
+
const text = fs.readFileSync(file, 'utf8')
|
|
245
|
+
const lines = text.split('\n')
|
|
246
|
+
for (let i = 0; i < lines.length; i++) {
|
|
247
|
+
scannedLines += 1
|
|
248
|
+
for (const [re, label] of PATTERNS) {
|
|
249
|
+
if (!re.test(lines[i])) continue
|
|
250
|
+
// 占位符形态(<...> / %VAR% / ${...})不算命中
|
|
251
|
+
const line = lines[i].replace(/<[^>]{1,60}>/g, '').replace(/%[A-Za-z_]+%/g, '').replace(/\$\{[^}]{1,60}\}/g, '')
|
|
252
|
+
if (!re.test(line)) continue
|
|
253
|
+
scanHits += 1
|
|
254
|
+
check(`脱敏 ${rel}:${i + 1} [${label}]`, false, lines[i].trim().slice(0, 120))
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
check(`脱敏扫描完成(${scanTargets.length - 1} 文件 / ${scannedLines} 行 / 命中 ${scanHits})`, scanHits === 0)
|
|
259
|
+
|
|
260
|
+
// 自检:**扫描器自己**不许夹带注入类标识(私人称呼 / 内部专有名 / 机器名 / 用户名 / 同步产品名)。
|
|
261
|
+
// 这些是「只有发布环境才知道的标识」,规则文本里本来就不该出现 —— 通用规则扫不了自身(规则文本
|
|
262
|
+
// 必然含形态样例),所以这里单独用注入规则再扫一遍,把「脚本自身已复核」变成可复跑断言。
|
|
263
|
+
// 注意:未注入时 INJECTED_PATTERNS 为空 ⇒ 本段是空判(会打印提示),发布前务必带全 BUILD_* 复跑。
|
|
264
|
+
const selfPath = path.join(ROOT, SELF_REL)
|
|
265
|
+
if (fs.existsSync(selfPath)) {
|
|
266
|
+
const selfText = fs.readFileSync(selfPath, 'utf8')
|
|
267
|
+
let selfHits = 0
|
|
268
|
+
for (const [re, label] of INJECTED_PATTERNS) {
|
|
269
|
+
if (!re.test(selfText)) continue
|
|
270
|
+
selfHits += 1
|
|
271
|
+
check(`自检 ${SELF_REL} [${label}](扫描器自身不得夹带该标识)`, false, re.source)
|
|
272
|
+
}
|
|
273
|
+
check(`自检 ${SELF_REL}:${INJECTED_PATTERNS.length} 条注入规则 / 命中 ${selfHits}`
|
|
274
|
+
+ (INJECTED_PATTERNS.length ? '' : '(未注入 → 空判,请带全 BUILD_* 复跑)'), selfHits === 0)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
console.log('\n' + (fail === 0 ? 'ALL CHECKS PASSED' : fail + ' CHECK(S) FAILED'))
|
|
278
|
+
process.exitCode = fail === 0 ? 0 : 1
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/verify-state.mjs —— 只读状态验证器(不依赖 GUI,也不需要面板打开)
|
|
3
|
+
//
|
|
4
|
+
// 它做的事:按配置层解析出数据根 / 工具目录 → 跑一遍六类体检 → 把结果与**配置来源自证**
|
|
5
|
+
// 一起打印出来。适合装在服务器上排查、或改完配置后确认"读到的是不是我想的那个数据根"。
|
|
6
|
+
//
|
|
7
|
+
// 用法:
|
|
8
|
+
// node scripts/verify-state.mjs [选项]
|
|
9
|
+
// --home <路径> 数据根(覆盖环境变量与默认值)
|
|
10
|
+
// --profile <名字> profile 名(默认 web)
|
|
11
|
+
// --tools <路径> 外部工具目录
|
|
12
|
+
// --config <路径> 配置文件 JSON
|
|
13
|
+
// --patch-manifest <路径> 补丁清单 JSON
|
|
14
|
+
// --web-url <url> 本实例基址(抓 boot 清单用;默认 http://127.0.0.1:3080)
|
|
15
|
+
// --json <路径> 把完整状态写成本地 JSON 文件
|
|
16
|
+
//
|
|
17
|
+
// 说明:**只读**。唯一的写操作是 --json 指定的文件(以及升级体检自己的缓存文件,
|
|
18
|
+
// 位置 = 配置层 cacheFile)。退出码:0 = 跑通(**不代表全绿** —— 有红黄项会照实打印);1 = 整体失败。
|
|
19
|
+
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import { buildState } from '../lib/state.js';
|
|
22
|
+
import { describeResolution, getConfig } from '../lib/config.js';
|
|
23
|
+
|
|
24
|
+
const argv = process.argv.slice(2);
|
|
25
|
+
const argOf = (name) => {
|
|
26
|
+
const i = argv.indexOf(name);
|
|
27
|
+
return i >= 0 ? (argv[i + 1] ?? null) : null;
|
|
28
|
+
};
|
|
29
|
+
const cli = {};
|
|
30
|
+
if (argOf('--home')) cli.home = argOf('--home');
|
|
31
|
+
if (argOf('--profile')) cli.profile = argOf('--profile');
|
|
32
|
+
if (argOf('--tools')) cli.toolsDir = argOf('--tools');
|
|
33
|
+
if (argOf('--config')) cli.configFile = argOf('--config');
|
|
34
|
+
if (argOf('--patch-manifest')) cli.patchManifestFile = argOf('--patch-manifest');
|
|
35
|
+
if (argOf('--web-url')) cli.webUrl = argOf('--web-url');
|
|
36
|
+
|
|
37
|
+
const jsonOut = argOf('--json');
|
|
38
|
+
const state = await buildState({ config: cli });
|
|
39
|
+
if (jsonOut) fs.writeFileSync(jsonOut, JSON.stringify(state, null, 2), 'utf8');
|
|
40
|
+
|
|
41
|
+
const cfg = getConfig(cli);
|
|
42
|
+
const line = (s = '') => console.log(s);
|
|
43
|
+
const mark = (s) => (s === 'green' ? '🟢' : s === 'yellow' ? '🟡' : s === 'red' ? '🔴' : '⬜');
|
|
44
|
+
const row = (item) => {
|
|
45
|
+
const name = item.name ?? item.id ?? item.title ?? '(未命名)';
|
|
46
|
+
const probs = (item.problems ?? []).length ? ' | ' + item.problems.join(' ; ') : '';
|
|
47
|
+
return ` ${mark(item.status)} [${item.kind}] ${name}${probs}`;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
line('============================================================');
|
|
51
|
+
line('dsh-ecosystem-panel 只读状态');
|
|
52
|
+
line(` 数据根 : ${state.meta.profileDir.replace(/[\\/]profiles[\\/].*$/, '')}`);
|
|
53
|
+
line(` profile : ${state.meta.profileDir}`);
|
|
54
|
+
line(` 技能目录 : ${state.meta.skillsDir}(ok=${state.meta.skillsDirOk})`);
|
|
55
|
+
line(` 工具目录 : ${state.meta.toolsDir ?? '(未配置 ⇒ 依赖它的类目会显式降级)'}`);
|
|
56
|
+
line(` 面板版本 : ${state.meta.panelVersion}`);
|
|
57
|
+
line(` boot 清单 : ${state.meta.bootEntries ?? '抓取失败'}${state.meta.bootError ? ' [' + state.meta.bootError + ']' : ''}`);
|
|
58
|
+
line(` YAML 解析 : ${state.meta.yamlAvailable ? '可用' : '不可用(cordis.patch.yml 与技能 frontmatter 会判不可解析)'}`);
|
|
59
|
+
line(` 停用类目 : ${state.meta.disabledGroups.length ? state.meta.disabledGroups.join(', ') : '(无)'}`);
|
|
60
|
+
if (state.meta.configErrors?.length) line(` ⚠ 配置错误: ${state.meta.configErrors.join(' | ')}`);
|
|
61
|
+
line(` 汇总 : 总 ${state.summary.total} = 🟢${state.summary.green} 🟡${state.summary.yellow} 🔴${state.summary.red}`);
|
|
62
|
+
line('============================================================');
|
|
63
|
+
|
|
64
|
+
line(`\n--- ① bundles(${state.groups.bundles.length})---`);
|
|
65
|
+
for (const b of state.groups.bundles) {
|
|
66
|
+
const ver = b.entityVersion ? ` v${b.entityVersion}` : (b.isCore ? ' (core)' : ' (无实体)');
|
|
67
|
+
const boot = b.bootLoaded === null ? ' [boot 未知]' : (b.bootLoaded ? ' [boot ✓]' : ' [boot ✗]');
|
|
68
|
+
line(row(b) + ver + boot);
|
|
69
|
+
}
|
|
70
|
+
line(`\n--- ② patch inserts(${state.groups.inserts.length})---`);
|
|
71
|
+
for (const i of state.groups.inserts) line(row(i));
|
|
72
|
+
line(`整个被禁用的 id: [${(state.groups.disabledPatchIds ?? []).join(', ')}] patch.yml 解析: ${state.meta.patchYamlError ?? '无错误'}`);
|
|
73
|
+
line(`\n--- ③ 补丁健康(${state.groups.patches.length})---`);
|
|
74
|
+
for (const p of state.groups.patches) {
|
|
75
|
+
line(row(p));
|
|
76
|
+
for (const c of p.checks ?? []) {
|
|
77
|
+
if (!c.fileExists) line(` 目标文件不存在/不可读: ${c.file}`);
|
|
78
|
+
for (const s of c.missing ?? []) line(` 缺特征 "${s}"`);
|
|
79
|
+
for (const s of c.forbiddenHit ?? []) line(` 残留应无特征 "${s}"`);
|
|
80
|
+
}
|
|
81
|
+
if (p.special && !p.special.ok) line(` ${p.special.detail}`);
|
|
82
|
+
}
|
|
83
|
+
line(`\n--- ④ 技能(${state.groups.skills.length})---`);
|
|
84
|
+
for (const s of state.groups.skills) line(row(s) + (s.fmName ? ` name="${s.fmName}"` : ''));
|
|
85
|
+
line(`\n--- ⑤ 升级体检(${state.groups.upgrades.length})---`);
|
|
86
|
+
const tier = state.upgradesMeta?.tier ?? {};
|
|
87
|
+
line(` 档位来源: ${tier.ok === true ? `${tier.tierSource}(CLI ${tier.cliVersion ?? '?'})` : `不可用:${tier.reason ?? '未提供'}`}`);
|
|
88
|
+
for (const u of state.groups.upgrades) {
|
|
89
|
+
const ver = u.latest ? `${u.local ?? '?'} → ${u.latest}` : (u.local ?? '?');
|
|
90
|
+
line(` ${mark(u.status)} [upgrade] ${u.name} ${ver} ${u.verdictLabel ?? ''}${(u.problems ?? []).length ? ' | ' + u.problems.join(' ; ') : ''}`);
|
|
91
|
+
}
|
|
92
|
+
line(`\n--- ⑥ 解耦健康(${state.groups.decoupling.length})---`);
|
|
93
|
+
line(` 脚本: ${state.decouplingMeta?.script ?? '(未配置)'} ${state.decouplingMeta?.scriptVersion ? 'v' + state.decouplingMeta.scriptVersion : ''} 范围: ${state.decouplingMeta?.scope ?? '-'}`);
|
|
94
|
+
for (const d of state.groups.decoupling) line(row(d));
|
|
95
|
+
line(`\n--- ⑦ 跨表准入(可选类目)---`);
|
|
96
|
+
line(` 状态: ${state.registryMeta?.status ?? 'n/a'}${state.registryMeta?.reason ? ' ' + state.registryMeta.reason : ''}`);
|
|
97
|
+
line(` 校验器: ${state.registryMeta?.lib ?? '(未配置)'}${state.registryMeta?.libVersion ? ' v' + state.registryMeta.libVersion : ''}`);
|
|
98
|
+
|
|
99
|
+
line('\n--- 配置来源自证(每个键到底是从哪读到的)---');
|
|
100
|
+
for (const [k, v] of Object.entries(describeResolution(cfg))) {
|
|
101
|
+
line(` ${k.padEnd(24)} = ${String(v.value ?? '').padEnd(10)} ← ${v.from}`);
|
|
102
|
+
}
|
|
103
|
+
line('\nverify-state done.');
|