dsh-isolation-gate 0.0.0-stage → 0.2.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/lib/store.js ADDED
@@ -0,0 +1,509 @@
1
+ /**
2
+ * 门禁 · 状态载体(唯一生效依据)
3
+ *
4
+ * 状态文件:`D:\.agents\门禁-授权.json`
5
+ *
6
+ * 设计要点(对齐 2026-10-08 户主令「授权以 GUI 开关为准」):
7
+ * · **唯一真身**:授权状态只存在这一个文件里;插件不保留内存副本、不缓存,
8
+ * 每次读取都重新读盘 ⇒ 面板不可能显示"已授权"而工具侧读到别的。
9
+ * · **默认禁**:文件不存在 / 解析失败 / 键未列出 ⇒ 一律 deny。
10
+ * 解析失败时**不覆盖**原文件(宁可停在"全禁",也不拿坏文件换好文件)。
11
+ * · **无 BOM**:`writeFileSync(path, text, 'utf8')` 不写 BOM;
12
+ * 读取时顺手剥掉可能的 `\uFEFF`(别的工具用 PS 的 `-Encoding UTF8` 写过)。
13
+ * · **原子替换**:先写 `<file>.tmp-<pid>-<ts>` 再 rename,避免半截 JSON。
14
+ * · 三级:deny(禁) / read(只读) / full(可读写删)——与规则措辞逐字对齐。
15
+ *
16
+ * 磁盘格式 v2(2026-10-21):
17
+ * {
18
+ * "version": 2,
19
+ * "groups": { "<组名>": { "note": "", "updatedAt": "…" } },
20
+ * "objects": { "<对象键>": { "level": "deny", "note": "", "group": "<组名>", "updatedAt": "…" } }
21
+ * }
22
+ * · **组只存在状态文件里**;名单仍是"一行一对象"的极简 TXT。
23
+ * · v1 的 `harnesses: { key: { level } }` **仍可读**:读入时自动迁移成 `objects`,
24
+ * 且不丢级别;下一次写盘才落成 v2(迁移是"读时兼容 + 写时升级")。
25
+ * · `state.harnesses` 保留为**只读别名**(非枚举属性,指向 `objects`)——旧代码/旧测试
26
+ * 读得到,但不会再被写进磁盘。
27
+ *
28
+ * 键空间:名字(D 根一级目录名)与绝对路径是两个键空间,永不互相匹配。
29
+ */
30
+
31
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
32
+ import { dirname } from 'node:path';
33
+ import { looksLikeAbsolutePath, normalizePathKey } from './roster.js';
34
+
35
+ /** 状态文件默认落点(户主建议位 · 供 agent / 工具侧读取)。 */
36
+ export const DEFAULT_STATE_PATH = 'D:\\.agents\\门禁-授权.json';
37
+
38
+ /** 当前磁盘格式代。 */
39
+ export const STATE_VERSION = 2;
40
+
41
+ /** 三级状态,顺序即"由紧到松",UI 按此顺序排按钮。 */
42
+ export const LEVELS = ['deny', 'read', 'full'];
43
+
44
+ /** 与规则措辞逐字对齐的级别名。 */
45
+ export const LEVEL_LABEL = {
46
+ deny: '禁',
47
+ read: '只读',
48
+ full: '可读写删',
49
+ };
50
+
51
+ /** 级别含义(UI 与 CLI 共用同一份文案,避免两边说不一样的话)。 */
52
+ export const LEVEL_HINT = {
53
+ deny: '完全不可访问(默认)',
54
+ read: '可读、可比对;不可写 / 删 / 搬 —— 对应户主说法「交叉验证」',
55
+ full: '可进入操作,含删减文件 —— 对应户主说法「可以进去操作 / 删减」',
56
+ };
57
+
58
+ /** 默认级别:未明确授权一律按"默认禁"执行。 */
59
+ export const DEFAULT_LEVEL = 'deny';
60
+
61
+ /** 组名上限(UI 与校验共用)。 */
62
+ export const MAX_GROUP_NAME = 40;
63
+
64
+ const README_LINE =
65
+ '门禁状态 · 唯一生效依据(GUI 侧边栏「门禁」开关写入)。' +
66
+ '未在本文件列出的键一律按 deny;本文件缺省也不代表授权,恰恰等于全禁。';
67
+
68
+ function nowIso(now) {
69
+ const value = now instanceof Date ? now : new Date();
70
+ return value.toISOString();
71
+ }
72
+
73
+ /** 空状态:全禁。 */
74
+ export function emptyState(now) {
75
+ const state = {
76
+ version: STATE_VERSION,
77
+ _readme: README_LINE,
78
+ defaultLevel: DEFAULT_LEVEL,
79
+ updatedAt: nowIso(now),
80
+ updatedBy: 'unknown',
81
+ groups: {},
82
+ objects: {},
83
+ };
84
+ // 旧读法别名:只在内存里,不写盘。
85
+ Object.defineProperty(state, 'harnesses', {
86
+ get() {
87
+ return state.objects;
88
+ },
89
+ enumerable: false,
90
+ configurable: true,
91
+ });
92
+ return state;
93
+ }
94
+
95
+ export function normalizeLevel(level) {
96
+ return typeof level === 'string' && LEVELS.includes(level) ? level : DEFAULT_LEVEL;
97
+ }
98
+
99
+ /** 键的种类:绝对路径 ⇒ `path`,其余 ⇒ `name`。两个键空间**不互相匹配**。 */
100
+ export function keyKindOf(value) {
101
+ return looksLikeAbsolutePath(value) ? 'path' : 'name';
102
+ }
103
+
104
+ /**
105
+ * 把键收敛成可比较的规范形:`{ kind, key }`(比较时再忽略大小写)。
106
+ * 空 / 非法 ⇒ `null`。
107
+ */
108
+ export function canonicalKey(value) {
109
+ if (typeof value !== 'string') return null;
110
+ const kind = keyKindOf(value);
111
+ if (kind === 'path') {
112
+ const key = normalizePathKey(value);
113
+ return key === null ? null : { kind, key };
114
+ }
115
+ const trimmed = value.trim();
116
+ return trimmed === '' ? null : { kind, key: trimmed };
117
+ }
118
+
119
+ function sameCanonicalKey(a, b) {
120
+ return a.kind === b.kind && a.key.toLowerCase() === b.key.toLowerCase();
121
+ }
122
+
123
+ /** 组名是否合法:非空、≤40 字符、无控制字符。 */
124
+ export function isValidGroupName(name) {
125
+ if (typeof name !== 'string') return { ok: false, error: '组名不是字符串' };
126
+ const trimmed = name.trim();
127
+ if (trimmed === '') return { ok: false, error: '组名为空' };
128
+ if (trimmed.length > MAX_GROUP_NAME) return { ok: false, error: `组名过长(>${MAX_GROUP_NAME} 字符)` };
129
+ if (/[\r\n\t]/.test(trimmed)) return { ok: false, error: '组名里不允许控制字符' };
130
+ return { ok: true, name: trimmed };
131
+ }
132
+
133
+ /** 在状态里找**已存的那个键**(保留户主写的大小写 / 形态),未命中返回 null。 */
134
+ function objectsOf(state) {
135
+ return state?.objects ?? state?.harnesses ?? {};
136
+ }
137
+
138
+ function findStoredKey(state, value) {
139
+ const want = canonicalKey(value);
140
+ if (want === null) return null;
141
+ for (const key of Object.keys(objectsOf(state))) {
142
+ const have = canonicalKey(key);
143
+ if (have !== null && sameCanonicalKey(have, want)) return key;
144
+ }
145
+ return null;
146
+ }
147
+
148
+ function normalizeGroups(raw) {
149
+ const groups = {};
150
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return groups;
151
+ for (const [name, cell] of Object.entries(raw)) {
152
+ const valid = isValidGroupName(name);
153
+ if (!valid.ok) continue;
154
+ const entry = cell !== null && typeof cell === 'object' && !Array.isArray(cell) ? cell : {};
155
+ groups[valid.name] = {
156
+ note: typeof entry.note === 'string' ? entry.note : '',
157
+ updatedAt: typeof entry.updatedAt === 'string' ? entry.updatedAt : '',
158
+ };
159
+ }
160
+ return groups;
161
+ }
162
+
163
+ /**
164
+ * 把任意 JSON 收敛成合法状态(v1 → v2 迁移在这里)。
165
+ * 多出来的字段丢弃,缺的补默认;**不丢级别、不丢组名**。
166
+ */
167
+ export function normalizeState(raw) {
168
+ const base = emptyState();
169
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
170
+ const broken = { ...base, _normalizedFrom: 'not-an-object' };
171
+ Object.defineProperty(broken, 'harnesses', { get: () => broken.objects, enumerable: false });
172
+ return broken;
173
+ }
174
+
175
+ const groups = normalizeGroups(raw.groups);
176
+ const objects = {};
177
+ // v2 优先;v1 的容器键叫 harnesses。
178
+ const source =
179
+ raw.objects !== null && typeof raw.objects === 'object' && !Array.isArray(raw.objects)
180
+ ? raw.objects
181
+ : raw.harnesses !== null && typeof raw.harnesses === 'object' && !Array.isArray(raw.harnesses)
182
+ ? raw.harnesses
183
+ : null;
184
+
185
+ if (source !== null) {
186
+ for (const [key, entry] of Object.entries(source)) {
187
+ if (typeof key !== 'string' || key.trim() === '') continue;
188
+ const cell = entry !== null && typeof entry === 'object' && !Array.isArray(entry) ? entry : {};
189
+ const rawLevel = typeof cell.level === 'string' ? cell.level : cell.value;
190
+ const groupValid = typeof cell.group === 'string' ? isValidGroupName(cell.group) : { ok: false };
191
+ const group = groupValid.ok ? groupValid.name : '';
192
+ if (group !== '' && !groups[group]) {
193
+ // 成员身上写了组、组表里没有 ⇒ 补齐组表(不丢归属)。
194
+ groups[group] = { note: '', updatedAt: '' };
195
+ }
196
+ objects[key.trim()] = {
197
+ level: normalizeLevel(rawLevel),
198
+ note: typeof cell.note === 'string' ? cell.note : '',
199
+ group,
200
+ updatedAt: typeof cell.updatedAt === 'string' ? cell.updatedAt : '',
201
+ };
202
+ }
203
+ }
204
+
205
+ const state = {
206
+ version: STATE_VERSION,
207
+ _readme: README_LINE,
208
+ defaultLevel: DEFAULT_LEVEL,
209
+ updatedAt: typeof raw.updatedAt === 'string' ? raw.updatedAt : base.updatedAt,
210
+ updatedBy: typeof raw.updatedBy === 'string' ? raw.updatedBy : 'unknown',
211
+ groups,
212
+ objects,
213
+ };
214
+ // 迁移痕迹(只读,不写盘):供 UI 显示"旧格式已自动迁移"。
215
+ if (raw.version !== STATE_VERSION || (raw.objects === undefined && raw.harnesses !== undefined)) {
216
+ Object.defineProperty(state, '_migratedFrom', {
217
+ value: raw.version === undefined ? 'v1' : `v${raw.version}`,
218
+ enumerable: false,
219
+ });
220
+ }
221
+ Object.defineProperty(state, 'harnesses', {
222
+ get() {
223
+ return state.objects;
224
+ },
225
+ enumerable: false,
226
+ configurable: true,
227
+ });
228
+ return state;
229
+ }
230
+
231
+ /**
232
+ * 读状态。**永不抛异常**,把"读不到"如实报成错误 + 全禁。
233
+ * @param {string} filePath 状态文件路径
234
+ */
235
+ export function readState(filePath) {
236
+ if (typeof filePath !== 'string' || filePath.trim() === '') {
237
+ return { ok: false, exists: false, state: emptyState(), error: '未配置状态文件路径(按全禁处理)' };
238
+ }
239
+ if (!existsSync(filePath)) {
240
+ return { ok: true, exists: false, state: emptyState(), error: null };
241
+ }
242
+ let text;
243
+ try {
244
+ text = readFileSync(filePath, 'utf8');
245
+ } catch (error) {
246
+ return { ok: false, exists: true, state: emptyState(), error: `状态文件读取失败:${error?.message ?? String(error)}` };
247
+ }
248
+ // 别的工具可能用带 BOM 的写法写过这个文件。
249
+ if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
250
+ if (text.trim() === '') {
251
+ return { ok: true, exists: true, state: emptyState(), error: null };
252
+ }
253
+ try {
254
+ const raw = JSON.parse(text);
255
+ const state = normalizeState(raw);
256
+ return {
257
+ ok: true,
258
+ exists: true,
259
+ state,
260
+ error: null,
261
+ migratedFrom: state._migratedFrom ?? null,
262
+ sourceVersion: typeof raw?.version === 'number' ? raw.version : raw?.version === undefined ? 1 : null,
263
+ };
264
+ } catch (error) {
265
+ return {
266
+ ok: false,
267
+ exists: true,
268
+ state: emptyState(),
269
+ error: `状态文件不是合法 JSON(按全禁处理,且不覆盖它):${error?.message ?? String(error)}`,
270
+ };
271
+ }
272
+ }
273
+
274
+ /** 写状态(原子替换 · 无 BOM · 只写 v2 形状)。 */
275
+ export function writeState(filePath, state, now) {
276
+ const normalized = normalizeState(state);
277
+ const payload = {
278
+ version: STATE_VERSION,
279
+ _readme: README_LINE,
280
+ defaultLevel: DEFAULT_LEVEL,
281
+ updatedAt: nowIso(now),
282
+ updatedBy: typeof state?.updatedBy === 'string' && state.updatedBy !== '' ? state.updatedBy : 'gui',
283
+ groups: normalized.groups,
284
+ objects: normalized.objects,
285
+ };
286
+ const text = `${JSON.stringify(payload, null, 2)}\n`;
287
+ mkdirSync(dirname(filePath), { recursive: true });
288
+ const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
289
+ writeFileSync(tmp, text, 'utf8');
290
+ renameSync(tmp, filePath);
291
+ return { ok: true, path: filePath, bytes: Buffer.byteLength(text, 'utf8'), state: payload };
292
+ }
293
+
294
+ /** 文件不存在则建一份"全禁"的(幂等 · 失败只报不抛)。 */
295
+ export function ensureStateFile(filePath, now) {
296
+ if (typeof filePath !== 'string' || filePath.trim() === '') {
297
+ return { created: false, error: '未配置状态文件路径' };
298
+ }
299
+ if (existsSync(filePath)) return { created: false, error: null };
300
+ try {
301
+ writeState(filePath, emptyState(now), now);
302
+ return { created: true, error: null };
303
+ } catch (error) {
304
+ return { created: false, error: `状态文件创建失败:${error?.message ?? String(error)}` };
305
+ }
306
+ }
307
+
308
+ /**
309
+ * 取某键的级别。**大小写不敏感**(Windows 路径 / 目录名都不区分大小写)。
310
+ * 键可以是**名字**(`Zcode`)也可以是**绝对路径**(`E:\资料\合作档案`)——
311
+ * 路径会先做同样的归一化再比;两类键**不会互相命中**。
312
+ */
313
+ export function levelOf(state, key) {
314
+ const stored = findStoredKey(state, key);
315
+ if (stored === null) return normalizeLevel(state?.defaultLevel);
316
+ return normalizeLevel(objectsOf(state)[stored]?.level);
317
+ }
318
+
319
+ /** 返回命中的**存储键**(保留户主写的大小写),未命中返回 null。 */
320
+ export function findEntryKey(state, key) {
321
+ return findStoredKey(state, key);
322
+ }
323
+
324
+ /** 取某键的组名(未分组 / 未命中 ⇒ '')。 */
325
+ export function groupOf(state, key) {
326
+ const stored = findStoredKey(state, key);
327
+ if (stored === null) return '';
328
+ const group = objectsOf(state)[stored]?.group;
329
+ return typeof group === 'string' ? group : '';
330
+ }
331
+
332
+ /**
333
+ * 生成改级别后的**新**状态对象(不改原对象;调用方负责落盘)。
334
+ * @param {object} state 当前状态
335
+ * @param {string} key 受管对象键:名字(D 根目录名)或**绝对路径**
336
+ * @param {string} level deny | read | full
337
+ */
338
+ export function withLevel(state, key, level, options = {}) {
339
+ const base = normalizeState(state);
340
+ const want = canonicalKey(key);
341
+ if (want === null) {
342
+ return { ok: false, error: '缺少受管对象键(名字或绝对路径)', state: base };
343
+ }
344
+ if (!LEVELS.includes(level)) {
345
+ return { ok: false, error: `级别不合法:${String(level)}(只接受 deny / read / full)`, state: base };
346
+ }
347
+ const stored = findStoredKey(base, key) ?? want.key;
348
+ const cell = base.objects[stored] ?? {};
349
+ base.objects[stored] = {
350
+ level,
351
+ note: typeof options.note === 'string' ? options.note : typeof cell.note === 'string' ? cell.note : '',
352
+ group: typeof cell.group === 'string' ? cell.group : '',
353
+ updatedAt: nowIso(options.now),
354
+ };
355
+ base.updatedBy = typeof options.by === 'string' && options.by !== '' ? options.by : 'gui';
356
+ base.updatedAt = nowIso(options.now);
357
+ return { ok: true, state: base, key: stored, level, kind: want.kind };
358
+ }
359
+
360
+ /**
361
+ * 生成"把对象移到某组"后的新状态(组不存在则自动建)。
362
+ * `group` 传空串 = 取消分组。
363
+ */
364
+ export function withGroup(state, key, group, options = {}) {
365
+ const base = normalizeState(state);
366
+ const want = canonicalKey(key);
367
+ if (want === null) return { ok: false, error: '缺少受管对象键(名字或绝对路径)', state: base };
368
+ let name = '';
369
+ if (typeof group === 'string' && group.trim() !== '') {
370
+ const valid = isValidGroupName(group);
371
+ if (!valid.ok) return { ok: false, error: valid.error, state: base };
372
+ name = valid.name;
373
+ if (!base.groups[name]) base.groups[name] = { note: '', updatedAt: nowIso(options.now) };
374
+ }
375
+ const stored = findStoredKey(base, key) ?? want.key;
376
+ const cell = base.objects[stored] ?? { level: DEFAULT_LEVEL, note: '', updatedAt: '' };
377
+ base.objects[stored] = { ...cell, group: name, updatedAt: nowIso(options.now) };
378
+ base.updatedBy = typeof options.by === 'string' && options.by !== '' ? options.by : 'gui';
379
+ base.updatedAt = nowIso(options.now);
380
+ return { ok: true, state: base, key: stored, group: name };
381
+ }
382
+
383
+ /**
384
+ * 生成"新建空组"后的新状态(还没有成员;之后用 withGroup 把对象放进去)。
385
+ * 已存在同名组 ⇒ 报错(不静默覆盖备注)。
386
+ */
387
+ export function withNewGroup(state, group, options = {}) {
388
+ const base = normalizeState(state);
389
+ const valid = isValidGroupName(group);
390
+ if (!valid.ok) return { ok: false, error: valid.error, state: base };
391
+ if (base.groups[valid.name]) return { ok: false, error: `组「${valid.name}」已存在`, state: base };
392
+ const stamp = nowIso(options.now);
393
+ base.groups[valid.name] = {
394
+ note: typeof options.note === 'string' ? options.note : '',
395
+ updatedAt: stamp,
396
+ };
397
+ base.updatedBy = typeof options.by === 'string' && options.by !== '' ? options.by : 'gui';
398
+ base.updatedAt = stamp;
399
+ return { ok: true, state: base, group: valid.name };
400
+ }
401
+
402
+ /**
403
+ * 组级一键切:把组内所有对象设为同一级。
404
+ * @returns {{ok:boolean,state:object,group:string,level:string,keys:string[],error?:string}}
405
+ */
406
+ export function withGroupLevel(state, group, level, options = {}) {
407
+ const base = normalizeState(state);
408
+ const valid = isValidGroupName(group);
409
+ if (!valid.ok) return { ok: false, error: valid.error, state: base, keys: [] };
410
+ if (!LEVELS.includes(level)) {
411
+ return { ok: false, error: `级别不合法:${String(level)}(只接受 deny / read / full)`, state: base, keys: [] };
412
+ }
413
+ const keys = Object.keys(base.objects).filter((key) => base.objects[key]?.group === valid.name);
414
+ if (keys.length === 0) {
415
+ return { ok: false, error: `组「${valid.name}」里没有成员`, state: base, keys: [] };
416
+ }
417
+ const stamp = nowIso(options.now);
418
+ for (const key of keys) base.objects[key] = { ...base.objects[key], level, updatedAt: stamp };
419
+ base.groups[valid.name] = { ...(base.groups[valid.name] ?? { note: '' }), updatedAt: stamp };
420
+ base.updatedBy = typeof options.by === 'string' && options.by !== '' ? options.by : 'gui';
421
+ base.updatedAt = stamp;
422
+ return { ok: true, state: base, group: valid.name, level, keys };
423
+ }
424
+
425
+ /** 生成"删除组"后的新状态:成员保留,只清 group 字段。 */
426
+ export function withoutGroup(state, group, options = {}) {
427
+ const base = normalizeState(state);
428
+ const valid = isValidGroupName(group);
429
+ if (!valid.ok) return { ok: false, error: valid.error, state: base, keys: [] };
430
+ if (!base.groups[valid.name]) return { ok: false, error: `组「${valid.name}」不存在`, state: base, keys: [] };
431
+ const keys = [];
432
+ for (const key of Object.keys(base.objects)) {
433
+ if (base.objects[key]?.group === valid.name) {
434
+ base.objects[key] = { ...base.objects[key], group: '', updatedAt: nowIso(options.now) };
435
+ keys.push(key);
436
+ }
437
+ }
438
+ delete base.groups[valid.name];
439
+ base.updatedAt = nowIso(options.now);
440
+ base.updatedBy = typeof options.by === 'string' && options.by !== '' ? options.by : 'gui';
441
+ return { ok: true, state: base, group: valid.name, keys };
442
+ }
443
+
444
+ /** 生成"删掉某个对象条目"后的新状态(名单移除时同步 prune)。 */
445
+ export function withoutObject(state, key, options = {}) {
446
+ const base = normalizeState(state);
447
+ const stored = findStoredKey(base, key);
448
+ if (stored === null) return { ok: false, error: `状态里没有「${key}」`, state: base };
449
+ delete base.objects[stored];
450
+ base.updatedAt = nowIso(options.now);
451
+ base.updatedBy = typeof options.by === 'string' && options.by !== '' ? options.by : 'gui';
452
+ return { ok: true, state: base, key: stored };
453
+ }
454
+
455
+ /**
456
+ * 清理孤儿键:状态里有、名单里没有的条目。
457
+ * @param {Array<{kind:string,key:string}|string>} rosterKeys
458
+ */
459
+ export function pruneState(state, rosterKeys, options = {}) {
460
+ const base = normalizeState(state);
461
+ const roster = (Array.isArray(rosterKeys) ? rosterKeys : [])
462
+ .map((item) => canonicalKey(typeof item === 'string' ? item : item?.key))
463
+ .filter((item) => item !== null);
464
+ const removed = [];
465
+ for (const key of Object.keys(base.objects)) {
466
+ const have = canonicalKey(key);
467
+ const hit = have !== null && roster.some((item) => sameCanonicalKey(item, have));
468
+ if (!hit) {
469
+ delete base.objects[key];
470
+ removed.push(key);
471
+ }
472
+ }
473
+ if (removed.length > 0) {
474
+ base.updatedAt = nowIso(options.now);
475
+ base.updatedBy = typeof options.by === 'string' && options.by !== '' ? options.by : 'gui';
476
+ }
477
+ return { ok: true, state: base, removed };
478
+ }
479
+
480
+ /** 组表:名字 + 成员数(给 UI / CLI)。 */
481
+ export function groupsOf(state) {
482
+ const base = normalizeState(state);
483
+ const counts = {};
484
+ for (const cell of Object.values(base.objects)) {
485
+ const group = typeof cell?.group === 'string' ? cell.group : '';
486
+ if (group !== '') counts[group] = (counts[group] ?? 0) + 1;
487
+ }
488
+ return Object.keys(base.groups)
489
+ .map((name) => ({ name, count: counts[name] ?? 0, note: base.groups[name]?.note ?? '', updatedAt: base.groups[name]?.updatedAt ?? '' }))
490
+ .sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
491
+ }
492
+
493
+ /**
494
+ * 状态里列出的、但不在名单里的键
495
+ * (显示为"状态里有但名单里没有 ⇒ 当前不受管辖")。
496
+ */
497
+ export function stateOnlyKeys(state, rosterKeys) {
498
+ const roster = (Array.isArray(rosterKeys) ? rosterKeys : [])
499
+ .map((item) => canonicalKey(typeof item === 'string' ? item : item?.key))
500
+ .filter((item) => item !== null);
501
+ return Object.keys(objectsOf(state)).filter((key) => {
502
+ const have = canonicalKey(key);
503
+ if (have === null) return true;
504
+ return !roster.some((item) => sameCanonicalKey(item, have));
505
+ });
506
+ }
507
+
508
+ /** 旧名(向后兼容):语义同 `stateOnlyKeys`。 */
509
+ export const stateOnlyNames = stateOnlyKeys;