@dreanzy/dsh-skill-pack 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/README.en.md +75 -0
- package/README.md +91 -0
- package/client/client.js +2024 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +6 -0
- package/lib/effective.d.ts +46 -0
- package/lib/effective.js +39 -0
- package/lib/hooks.d.ts +32 -0
- package/lib/hooks.js +403 -0
- package/lib/index.d.ts +10 -0
- package/lib/index.js +109 -0
- package/lib/inventory.d.ts +54 -0
- package/lib/inventory.js +34 -0
- package/lib/policy.d.ts +85 -0
- package/lib/policy.js +386 -0
- package/lib/reconcile.d.ts +18 -0
- package/lib/reconcile.js +60 -0
- package/lib/routes.d.ts +102 -0
- package/lib/routes.js +511 -0
- package/lib/snapshot.d.ts +55 -0
- package/lib/snapshot.js +135 -0
- package/lib/store.d.ts +46 -0
- package/lib/store.js +434 -0
- package/lib/types.d.ts +120 -0
- package/lib/types.js +13 -0
- package/locale/en.json +47 -0
- package/locale/zh.json +47 -0
- package/package.json +79 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 清单读取与官方判定。
|
|
3
|
+
*
|
|
4
|
+
* 只有一条硬约束:**清单不完整时不许拿它算隐藏集**。不完整意味着有些名字暂时
|
|
5
|
+
* 没读到,而「没读到」与「清单里不存在」在求值上看起来一样——用它重算会把
|
|
6
|
+
* 本来关着的项当成不需要处理而悄悄放行。宁可这一轮不动,也不要放行。
|
|
7
|
+
*/
|
|
8
|
+
/** `ctx.skills.snapshot()` 返回条目里本插件用到的字段。 */
|
|
9
|
+
export interface SkillSummaryLike {
|
|
10
|
+
readonly name: string;
|
|
11
|
+
readonly description: string;
|
|
12
|
+
readonly path?: string;
|
|
13
|
+
readonly provider: string;
|
|
14
|
+
readonly source: string;
|
|
15
|
+
readonly invocation?: {
|
|
16
|
+
readonly modelInvocable?: boolean;
|
|
17
|
+
readonly userInvocable?: boolean;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/** 宿主 `skills` 服务在本插件用到的面。 */
|
|
21
|
+
export interface SkillsFace {
|
|
22
|
+
snapshot(options: {
|
|
23
|
+
cwd?: string;
|
|
24
|
+
scope?: unknown;
|
|
25
|
+
}): Promise<{
|
|
26
|
+
skills: readonly SkillSummaryLike[];
|
|
27
|
+
complete: boolean;
|
|
28
|
+
}>;
|
|
29
|
+
get(name: string, options?: {
|
|
30
|
+
cwd?: string;
|
|
31
|
+
scope?: unknown;
|
|
32
|
+
}): Promise<{
|
|
33
|
+
readonly content?: string;
|
|
34
|
+
} | undefined>;
|
|
35
|
+
register(skill: {
|
|
36
|
+
readonly name: string;
|
|
37
|
+
readonly description: string;
|
|
38
|
+
readonly content: string;
|
|
39
|
+
readonly source: string;
|
|
40
|
+
readonly path?: string;
|
|
41
|
+
readonly invocation?: {
|
|
42
|
+
readonly modelInvocable: boolean;
|
|
43
|
+
readonly userInvocable: boolean;
|
|
44
|
+
};
|
|
45
|
+
}): () => void;
|
|
46
|
+
}
|
|
47
|
+
export interface Inventory {
|
|
48
|
+
readonly rows: readonly SkillSummaryLike[];
|
|
49
|
+
readonly complete: boolean;
|
|
50
|
+
}
|
|
51
|
+
export declare function isOfficialSkill(entry: {
|
|
52
|
+
path?: string;
|
|
53
|
+
provider?: string;
|
|
54
|
+
}): boolean;
|
package/lib/inventory.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 清单读取与官方判定。
|
|
3
|
+
*
|
|
4
|
+
* 只有一条硬约束:**清单不完整时不许拿它算隐藏集**。不完整意味着有些名字暂时
|
|
5
|
+
* 没读到,而「没读到」与「清单里不存在」在求值上看起来一样——用它重算会把
|
|
6
|
+
* 本来关着的项当成不需要处理而悄悄放行。宁可这一轮不动,也不要放行。
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* 清单读取只剩「调宿主 `snapshot()` 一次」这一件事。
|
|
10
|
+
*
|
|
11
|
+
* 缓存与失效搬到了 `snapshot.ts`:那里的键含 scope 身份,而且与 hooks、路由共用,
|
|
12
|
+
* 所以面板与模型看到的是同一次求值的结果。这里刻意不再留第二份缓存——两份缓存
|
|
13
|
+
* 必然分叉,早先按「cwd + 有没有 scope」建键时,不同 preset 的会话会读到彼此的清单。
|
|
14
|
+
*
|
|
15
|
+
* 读清单的调用点只剩 hooks 的 `readFresh`;`cwd` 缺省时读全局清单(`snapshot({})`),
|
|
16
|
+
* 不要用空字符串表示「全局」:`''` 会被当作一个目录去解析工程根。
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* 官方 `@deepseek-ai` 基础设施 skill。
|
|
20
|
+
*
|
|
21
|
+
* 判定只能靠路径与 provider 白名单,不能靠 `source`:官方那批由同一个
|
|
22
|
+
* `filesystem` provider 持有,`source` 与用户自己的 custom skill 完全相同,
|
|
23
|
+
* 按 source 过滤会把用户技能一起误伤。
|
|
24
|
+
*
|
|
25
|
+
* 第二条判据覆盖「没有 path」的官方 provider(`dsh-office` 注册的 `office-*`
|
|
26
|
+
* 不填 path)。名单是白名单:名单外的第三方 provider 照常展示。
|
|
27
|
+
*/
|
|
28
|
+
const OFFICIAL_SCOPE = '@deepseek-ai';
|
|
29
|
+
const OFFICIAL_PROVIDERS = new Set(['dsh-office']);
|
|
30
|
+
export function isOfficialSkill(entry) {
|
|
31
|
+
if (entry.path !== undefined && entry.path.includes(OFFICIAL_SCOPE))
|
|
32
|
+
return true;
|
|
33
|
+
return entry.provider !== undefined && OFFICIAL_PROVIDERS.has(entry.provider);
|
|
34
|
+
}
|
package/lib/policy.d.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { type Bindings, type GroupRegistry, type Pack, type PackBook, type ParseResult, type UiState } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* 工作区键归一:同一目录的不同写法必须落到同一个键。
|
|
4
|
+
*
|
|
5
|
+
* 反斜杠转正斜杠、剥除尾部斜杠、Windows 下转小写;盘符根目录保留尾斜杠。
|
|
6
|
+
* 这份实现是**唯一一份**:绑定表的键(store)、求值缓存的键(snapshot),
|
|
7
|
+
* 以及任何按 cwd 找绑定的调用点都调它。复制一份必然分叉,表现是「绑定了却不生效」。
|
|
8
|
+
*/
|
|
9
|
+
export declare function normalizeCwd(cwd: string | null): string | null;
|
|
10
|
+
/** `packs.json` 的解析结果:pack 名册与 `ui` 段同住一个文件。 */
|
|
11
|
+
export interface ParsedPacks {
|
|
12
|
+
readonly book: PackBook;
|
|
13
|
+
readonly ui: UiState;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* 解析 `packs.json`。
|
|
17
|
+
*
|
|
18
|
+
* `default` 缺席时补一个空 pack:它是求值链的终点,不能不存在。补的行为留一条
|
|
19
|
+
* warning,因为正常写入路径永远会写出 `default`,缺席意味着文件被外部改坏。
|
|
20
|
+
*/
|
|
21
|
+
export declare function parsePacks(text: string | undefined): ParseResult<ParsedPacks>;
|
|
22
|
+
/** 解析 `workspaces.json`。绑定键与 `extra` 都在读取时归一,缺失即空表。 */
|
|
23
|
+
export declare function parseBindings(text: string | undefined): ParseResult<Bindings>;
|
|
24
|
+
/**
|
|
25
|
+
* 解析组定义。
|
|
26
|
+
*
|
|
27
|
+
* 成员搬进 `assignments`(项为主键),查询「这一项属于哪个组」是 O(1);
|
|
28
|
+
* 反查「这一组有哪些项」扫一遍即可,几百项的量级不值得维护两份索引。
|
|
29
|
+
* 指向未知分组的条目保留不删:组被临时删除再建回来时语义自动恢复。
|
|
30
|
+
*/
|
|
31
|
+
export declare function parseGroups(text: string | undefined): ParseResult<GroupRegistry>;
|
|
32
|
+
/**
|
|
33
|
+
* 序列化 `packs.json`。
|
|
34
|
+
*
|
|
35
|
+
* 键排序 + 两空格 + 结尾换行是硬要求:同一份策略必须序列化成同一串字节,
|
|
36
|
+
* 否则每次保存都产生一次无意义的 git 差异。`ui` 段总是写全,`default` 总是在。
|
|
37
|
+
*/
|
|
38
|
+
export declare function serializePacks(packs: Readonly<Record<string, Pack>>, ui: UiState): string;
|
|
39
|
+
/** 序列化 `workspaces.json`:绑定键排序,`extra` 保持用户添加顺序。 */
|
|
40
|
+
export declare function serializeBindings(bindings: Bindings): string;
|
|
41
|
+
export declare function serializeGroups(registry: GroupRegistry): string;
|
|
42
|
+
/** 取名册里的 default。解析保证它存在,这里只是给类型收窄一个出口。 */
|
|
43
|
+
export declare function defaultPackOf(book: PackBook): Pack;
|
|
44
|
+
/** 一次求值的结论:值、做出决定的 pack,以及落在 pack 的哪一级。 */
|
|
45
|
+
export interface Decision {
|
|
46
|
+
readonly value: boolean;
|
|
47
|
+
/** 做出决定的 pack id;取内置缺省时是 `default`。 */
|
|
48
|
+
readonly packId: string;
|
|
49
|
+
/** `builtin` 表示两层都没有记录,取缺省值 `true`。 */
|
|
50
|
+
readonly via: 'item' | 'group' | 'builtin';
|
|
51
|
+
}
|
|
52
|
+
/** 求值上下文:工作区绑定的 pack、default pack 与组归属。 */
|
|
53
|
+
export interface ResolverInput {
|
|
54
|
+
/** 工作区绑定的 pack;未绑定、绑定悬空或绑定到 `default` 时为 `null`。 */
|
|
55
|
+
readonly pack: Pack | null;
|
|
56
|
+
/** `pack` 的 id;`pack` 为 `null` 时忽略。 */
|
|
57
|
+
readonly packId?: string;
|
|
58
|
+
/** 求值链的终点:`packs.json` 里的 `default`。 */
|
|
59
|
+
readonly defaultPack: Pack;
|
|
60
|
+
/**
|
|
61
|
+
* 组定义与归属。只有仍存在于 `groups` 里的 id 才参与组级求值:指向已删分组的
|
|
62
|
+
* 残留归属若照旧去 `pack.groups` 取值,一个早已不存在的组还能靠遗留开关
|
|
63
|
+
* 决定 skill 可不可见,而面板上它显示为「未分组」。
|
|
64
|
+
*/
|
|
65
|
+
readonly groups: GroupRegistry;
|
|
66
|
+
}
|
|
67
|
+
export interface Resolver {
|
|
68
|
+
/** 某一项此刻是否开着。 */
|
|
69
|
+
isOpen(name: string): boolean;
|
|
70
|
+
/** 某一项的分组 id,未分组时 `undefined`。 */
|
|
71
|
+
groupOf(name: string): string | undefined;
|
|
72
|
+
/** 某一项的决定来源;都没有记录时返回内置缺省。 */
|
|
73
|
+
decide(name: string): Decision;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* 建一个求值器,链序是:
|
|
77
|
+
*
|
|
78
|
+
* ```
|
|
79
|
+
* pack.items[项] ?? pack.groups[组] ?? default.items[项] ?? default.groups[组] ?? true
|
|
80
|
+
* ```
|
|
81
|
+
*
|
|
82
|
+
* 用 `!== undefined` 而不是 `||`:`false` 是「明令关」,必须原样保留。
|
|
83
|
+
* 绑定悬空时调用方传 `pack: null`,求值自然退回 `default`。
|
|
84
|
+
*/
|
|
85
|
+
export declare function createResolver(input: ResolverInput): Resolver;
|
package/lib/policy.js
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import { DEFAULT_PACK_ID, DEFAULT_PACK_NAME, EMPTY_BINDINGS, EMPTY_GROUPS, EMPTY_PACK, EMPTY_UI, } from './types.js';
|
|
2
|
+
/** 单个域的名字上限,只为防止文件被写成无界清单。 */
|
|
3
|
+
const MAX_NAMES = 2000;
|
|
4
|
+
function isRecord(value) {
|
|
5
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
function sortMap(map) {
|
|
8
|
+
const out = {};
|
|
9
|
+
for (const key of Object.keys(map).sort()) {
|
|
10
|
+
const value = map[key];
|
|
11
|
+
if (value !== undefined)
|
|
12
|
+
out[key] = value;
|
|
13
|
+
}
|
|
14
|
+
return out;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* 解析一张「名字 → 布尔」表。
|
|
18
|
+
*
|
|
19
|
+
* **不校验名字形状**:表里记录的是用户的决定,不是当前清单的投影;
|
|
20
|
+
* 名字暂时不存在(还没装、已卸载)时仍要保留,否则卸载再装回来决定就没了。
|
|
21
|
+
*/
|
|
22
|
+
function parseMap(raw, label, warnings) {
|
|
23
|
+
const out = {};
|
|
24
|
+
if (raw === undefined)
|
|
25
|
+
return out;
|
|
26
|
+
if (!isRecord(raw)) {
|
|
27
|
+
warnings.push(`${label} 不是对象,已忽略`);
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
const names = Object.keys(raw).sort();
|
|
31
|
+
if (names.length > MAX_NAMES) {
|
|
32
|
+
warnings.push(`${label} 超过 ${MAX_NAMES} 项,已只保留前 ${MAX_NAMES} 项`);
|
|
33
|
+
names.length = MAX_NAMES;
|
|
34
|
+
}
|
|
35
|
+
for (const name of names) {
|
|
36
|
+
const value = raw[name];
|
|
37
|
+
if (typeof value !== 'boolean') {
|
|
38
|
+
warnings.push(`${label}.${name} 不是布尔值,已忽略`);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
out[name] = value;
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
function readJson(text, warnings) {
|
|
46
|
+
if (text === undefined || text.trim() === '')
|
|
47
|
+
return null;
|
|
48
|
+
let raw;
|
|
49
|
+
try {
|
|
50
|
+
raw = JSON.parse(text);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
warnings.push(`文件不是合法 JSON,已按空策略处理:${String(error)}`);
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
if (!isRecord(raw)) {
|
|
57
|
+
warnings.push('文件顶层不是对象,已按空策略处理');
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
return raw;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* 工作区键归一:同一目录的不同写法必须落到同一个键。
|
|
64
|
+
*
|
|
65
|
+
* 反斜杠转正斜杠、剥除尾部斜杠、Windows 下转小写;盘符根目录保留尾斜杠。
|
|
66
|
+
* 这份实现是**唯一一份**:绑定表的键(store)、求值缓存的键(snapshot),
|
|
67
|
+
* 以及任何按 cwd 找绑定的调用点都调它。复制一份必然分叉,表现是「绑定了却不生效」。
|
|
68
|
+
*/
|
|
69
|
+
export function normalizeCwd(cwd) {
|
|
70
|
+
if (cwd === null)
|
|
71
|
+
return null;
|
|
72
|
+
const trimmed = cwd.trim();
|
|
73
|
+
if (trimmed === '')
|
|
74
|
+
return null;
|
|
75
|
+
let unified = trimmed.replace(/\\/gu, '/');
|
|
76
|
+
if (process.platform === 'win32')
|
|
77
|
+
unified = unified.toLowerCase();
|
|
78
|
+
if (unified.length > 1) {
|
|
79
|
+
const stripped = unified.replace(/\/+$/u, '');
|
|
80
|
+
if (stripped === '')
|
|
81
|
+
return '/';
|
|
82
|
+
// `C:/` 去掉尾斜杠会变成 `C:`,那不是一个目录。
|
|
83
|
+
return /^[a-z]:$/iu.test(stripped) ? stripped + '/' : stripped;
|
|
84
|
+
}
|
|
85
|
+
return unified;
|
|
86
|
+
}
|
|
87
|
+
function parseUi(raw, warnings) {
|
|
88
|
+
if (raw === undefined)
|
|
89
|
+
return { ...EMPTY_UI };
|
|
90
|
+
if (!isRecord(raw)) {
|
|
91
|
+
warnings.push('ui 不是对象,已用缺省值');
|
|
92
|
+
return { ...EMPTY_UI };
|
|
93
|
+
}
|
|
94
|
+
// chip 缺省开着:文件里没写这个键时,用户期待入口在。
|
|
95
|
+
// 未知键(例如已废弃的 `showSidebarEntry`)原样忽略,不报警告。
|
|
96
|
+
return {
|
|
97
|
+
showOfficialSkills: typeof raw['showOfficialSkills'] === 'boolean' ? raw['showOfficialSkills'] : false,
|
|
98
|
+
showChip: typeof raw['showChip'] === 'boolean' ? raw['showChip'] : true,
|
|
99
|
+
defaultTab: raw['defaultTab'] === 'packs' || raw['defaultTab'] === 'workspace' ? raw['defaultTab'] : 'groups',
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function parsePack(id, raw, warnings) {
|
|
103
|
+
if (!isRecord(raw)) {
|
|
104
|
+
warnings.push(`packs.${id} 不是对象,已按空 pack 处理`);
|
|
105
|
+
return { name: id, items: {}, groups: {} };
|
|
106
|
+
}
|
|
107
|
+
const nameRaw = raw['name'];
|
|
108
|
+
const name = typeof nameRaw === 'string' && nameRaw.trim() !== '' ? nameRaw : id;
|
|
109
|
+
if (name !== nameRaw)
|
|
110
|
+
warnings.push(`packs.${id}.name 缺失或不是非空字符串,已用 id 代替`);
|
|
111
|
+
return {
|
|
112
|
+
name,
|
|
113
|
+
items: parseMap(raw['items'], `packs.${id}.items`, warnings),
|
|
114
|
+
groups: parseMap(raw['groups'], `packs.${id}.groups`, warnings),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* 解析 `packs.json`。
|
|
119
|
+
*
|
|
120
|
+
* `default` 缺席时补一个空 pack:它是求值链的终点,不能不存在。补的行为留一条
|
|
121
|
+
* warning,因为正常写入路径永远会写出 `default`,缺席意味着文件被外部改坏。
|
|
122
|
+
*/
|
|
123
|
+
export function parsePacks(text) {
|
|
124
|
+
const warnings = [];
|
|
125
|
+
const raw = readJson(text, warnings);
|
|
126
|
+
const ui = parseUi(raw?.['ui'], warnings);
|
|
127
|
+
const packs = {};
|
|
128
|
+
if (raw !== null) {
|
|
129
|
+
const rawPacks = raw['packs'];
|
|
130
|
+
if (rawPacks !== undefined) {
|
|
131
|
+
if (!isRecord(rawPacks)) {
|
|
132
|
+
warnings.push('packs 不是对象,已忽略');
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
const ids = Object.keys(rawPacks).sort();
|
|
136
|
+
if (ids.length > MAX_NAMES) {
|
|
137
|
+
warnings.push(`packs 超过 ${MAX_NAMES} 个,已只保留前 ${MAX_NAMES} 个`);
|
|
138
|
+
ids.length = MAX_NAMES;
|
|
139
|
+
}
|
|
140
|
+
for (const id of ids) {
|
|
141
|
+
if (id === '') {
|
|
142
|
+
warnings.push('packs 里有空 id 的条目,已忽略');
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const pack = parsePack(id, rawPacks[id], warnings);
|
|
146
|
+
// 内置 pack 的显示名固定「默认」:文件里可能是旧名(default 等),读侧一并归一。
|
|
147
|
+
packs[id] = id === DEFAULT_PACK_ID ? { ...pack, name: DEFAULT_PACK_NAME } : pack;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (packs[DEFAULT_PACK_ID] === undefined) {
|
|
153
|
+
if (raw !== null)
|
|
154
|
+
warnings.push('packs 缺少 default,已补一个空 pack');
|
|
155
|
+
packs[DEFAULT_PACK_ID] = EMPTY_PACK;
|
|
156
|
+
}
|
|
157
|
+
return { value: { book: { packs }, ui }, warnings };
|
|
158
|
+
}
|
|
159
|
+
/** 解析 `workspaces.json`。绑定键与 `extra` 都在读取时归一,缺失即空表。 */
|
|
160
|
+
export function parseBindings(text) {
|
|
161
|
+
const warnings = [];
|
|
162
|
+
const raw = readJson(text, warnings);
|
|
163
|
+
if (raw === null)
|
|
164
|
+
return { value: EMPTY_BINDINGS, warnings };
|
|
165
|
+
const bindings = {};
|
|
166
|
+
const rawBindings = raw['bindings'];
|
|
167
|
+
if (rawBindings !== undefined) {
|
|
168
|
+
if (!isRecord(rawBindings)) {
|
|
169
|
+
warnings.push('bindings 不是对象,已忽略');
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
const keys = Object.keys(rawBindings).sort();
|
|
173
|
+
if (keys.length > MAX_NAMES) {
|
|
174
|
+
warnings.push(`bindings 超过 ${MAX_NAMES} 项,已只保留前 ${MAX_NAMES} 项`);
|
|
175
|
+
keys.length = MAX_NAMES;
|
|
176
|
+
}
|
|
177
|
+
for (const key of keys) {
|
|
178
|
+
const value = rawBindings[key];
|
|
179
|
+
if (typeof value !== 'string' || value === '') {
|
|
180
|
+
warnings.push(`bindings.${key} 不是 pack id,已忽略`);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const normalized = normalizeCwd(key);
|
|
184
|
+
if (normalized === null) {
|
|
185
|
+
warnings.push('bindings 里有空路径,已忽略');
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
bindings[normalized] = value;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const extra = [];
|
|
193
|
+
const rawExtra = raw['extra'];
|
|
194
|
+
if (rawExtra !== undefined) {
|
|
195
|
+
if (!Array.isArray(rawExtra)) {
|
|
196
|
+
warnings.push('extra 不是数组,已忽略');
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
let capped = false;
|
|
200
|
+
for (const entry of rawExtra) {
|
|
201
|
+
if (typeof entry !== 'string') {
|
|
202
|
+
warnings.push('extra 里有非字符串元素,已忽略');
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (extra.length >= MAX_NAMES) {
|
|
206
|
+
if (!capped) {
|
|
207
|
+
warnings.push(`extra 超过 ${MAX_NAMES} 项,已只保留前 ${MAX_NAMES} 项`);
|
|
208
|
+
capped = true;
|
|
209
|
+
}
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
const normalized = normalizeCwd(entry);
|
|
213
|
+
if (normalized === null)
|
|
214
|
+
continue;
|
|
215
|
+
if (!extra.includes(normalized))
|
|
216
|
+
extra.push(normalized);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return { value: { bindings, extra }, warnings };
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* 解析组定义。
|
|
224
|
+
*
|
|
225
|
+
* 成员搬进 `assignments`(项为主键),查询「这一项属于哪个组」是 O(1);
|
|
226
|
+
* 反查「这一组有哪些项」扫一遍即可,几百项的量级不值得维护两份索引。
|
|
227
|
+
* 指向未知分组的条目保留不删:组被临时删除再建回来时语义自动恢复。
|
|
228
|
+
*/
|
|
229
|
+
export function parseGroups(text) {
|
|
230
|
+
const warnings = [];
|
|
231
|
+
const raw = readJson(text, warnings);
|
|
232
|
+
if (raw === null)
|
|
233
|
+
return { value: EMPTY_GROUPS, warnings };
|
|
234
|
+
const groups = [];
|
|
235
|
+
const seen = new Set();
|
|
236
|
+
const rawGroups = raw['groups'];
|
|
237
|
+
if (rawGroups !== undefined) {
|
|
238
|
+
if (!Array.isArray(rawGroups)) {
|
|
239
|
+
warnings.push('groups 不是数组,已忽略');
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
if (rawGroups.length > MAX_NAMES) {
|
|
243
|
+
warnings.push(`groups 超过 ${MAX_NAMES} 个,已只保留前 ${MAX_NAMES} 个`);
|
|
244
|
+
rawGroups.length = MAX_NAMES;
|
|
245
|
+
}
|
|
246
|
+
for (const entry of rawGroups) {
|
|
247
|
+
if (!isRecord(entry)) {
|
|
248
|
+
warnings.push('groups 里有非对象元素,已忽略');
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
const id = entry['id'];
|
|
252
|
+
const name = entry['name'];
|
|
253
|
+
if (typeof id !== 'string' || id === '' || typeof name !== 'string' || name === '') {
|
|
254
|
+
warnings.push('groups 里有缺少 id 或 name 的元素,已忽略');
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (seen.has(id)) {
|
|
258
|
+
warnings.push(`分组 id 重复(${id}),已忽略后来者`);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
seen.add(id);
|
|
262
|
+
groups.push({ id, name });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const assignments = {};
|
|
267
|
+
const rawAssignments = raw['assignments'];
|
|
268
|
+
if (rawAssignments !== undefined) {
|
|
269
|
+
if (!isRecord(rawAssignments)) {
|
|
270
|
+
warnings.push('assignments 不是对象,已忽略');
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
const names = Object.keys(rawAssignments).sort();
|
|
274
|
+
if (names.length > MAX_NAMES) {
|
|
275
|
+
warnings.push(`assignments 超过 ${MAX_NAMES} 项,已只保留前 ${MAX_NAMES} 项`);
|
|
276
|
+
names.length = MAX_NAMES;
|
|
277
|
+
}
|
|
278
|
+
for (const name of names) {
|
|
279
|
+
const groupId = rawAssignments[name];
|
|
280
|
+
if (typeof groupId !== 'string' || groupId === '') {
|
|
281
|
+
warnings.push(`assignments.${name} 不是分组 id,已忽略`);
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
assignments[name] = groupId;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return { value: { groups, assignments }, warnings };
|
|
289
|
+
}
|
|
290
|
+
function serializeRecord(entries) {
|
|
291
|
+
return `${JSON.stringify(entries, null, 2)}\n`;
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* 序列化 `packs.json`。
|
|
295
|
+
*
|
|
296
|
+
* 键排序 + 两空格 + 结尾换行是硬要求:同一份策略必须序列化成同一串字节,
|
|
297
|
+
* 否则每次保存都产生一次无意义的 git 差异。`ui` 段总是写全,`default` 总是在。
|
|
298
|
+
*/
|
|
299
|
+
export function serializePacks(packs, ui) {
|
|
300
|
+
const out = { version: 1, ui: { ...ui } };
|
|
301
|
+
const packEntries = {};
|
|
302
|
+
for (const id of Object.keys(packs).sort()) {
|
|
303
|
+
const pack = packs[id];
|
|
304
|
+
if (pack === undefined)
|
|
305
|
+
continue;
|
|
306
|
+
const entry = { name: id === DEFAULT_PACK_ID ? DEFAULT_PACK_NAME : pack.name };
|
|
307
|
+
if (Object.keys(pack.items).length > 0)
|
|
308
|
+
entry['items'] = sortMap(pack.items);
|
|
309
|
+
if (Object.keys(pack.groups).length > 0)
|
|
310
|
+
entry['groups'] = sortMap(pack.groups);
|
|
311
|
+
packEntries[id] = entry;
|
|
312
|
+
}
|
|
313
|
+
out['packs'] = packEntries;
|
|
314
|
+
return serializeRecord(out);
|
|
315
|
+
}
|
|
316
|
+
/** 序列化 `workspaces.json`:绑定键排序,`extra` 保持用户添加顺序。 */
|
|
317
|
+
export function serializeBindings(bindings) {
|
|
318
|
+
const out = {};
|
|
319
|
+
for (const key of Object.keys(bindings.bindings).sort()) {
|
|
320
|
+
const packId = bindings.bindings[key];
|
|
321
|
+
if (packId !== undefined)
|
|
322
|
+
out[key] = packId;
|
|
323
|
+
}
|
|
324
|
+
return serializeRecord({ version: 1, bindings: out, extra: [...bindings.extra] });
|
|
325
|
+
}
|
|
326
|
+
export function serializeGroups(registry) {
|
|
327
|
+
const groups = [...registry.groups].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
328
|
+
const assignments = {};
|
|
329
|
+
for (const name of Object.keys(registry.assignments).sort()) {
|
|
330
|
+
const groupId = registry.assignments[name];
|
|
331
|
+
if (groupId !== undefined)
|
|
332
|
+
assignments[name] = groupId;
|
|
333
|
+
}
|
|
334
|
+
return serializeRecord({ version: 1, groups, assignments });
|
|
335
|
+
}
|
|
336
|
+
/** 取名册里的 default。解析保证它存在,这里只是给类型收窄一个出口。 */
|
|
337
|
+
export function defaultPackOf(book) {
|
|
338
|
+
return book.packs[DEFAULT_PACK_ID] ?? EMPTY_PACK;
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* 建一个求值器,链序是:
|
|
342
|
+
*
|
|
343
|
+
* ```
|
|
344
|
+
* pack.items[项] ?? pack.groups[组] ?? default.items[项] ?? default.groups[组] ?? true
|
|
345
|
+
* ```
|
|
346
|
+
*
|
|
347
|
+
* 用 `!== undefined` 而不是 `||`:`false` 是「明令关」,必须原样保留。
|
|
348
|
+
* 绑定悬空时调用方传 `pack: null`,求值自然退回 `default`。
|
|
349
|
+
*/
|
|
350
|
+
export function createResolver(input) {
|
|
351
|
+
const { pack, defaultPack, groups } = input;
|
|
352
|
+
const packId = pack === null ? DEFAULT_PACK_ID : (input.packId ?? DEFAULT_PACK_ID);
|
|
353
|
+
const groupOf = (name) => {
|
|
354
|
+
const groupId = groups.assignments[name];
|
|
355
|
+
if (groupId === undefined || groupId === '')
|
|
356
|
+
return undefined;
|
|
357
|
+
for (const group of groups.groups) {
|
|
358
|
+
if (group.id === groupId)
|
|
359
|
+
return groupId;
|
|
360
|
+
}
|
|
361
|
+
return undefined;
|
|
362
|
+
};
|
|
363
|
+
const decide = (name) => {
|
|
364
|
+
const groupId = groupOf(name);
|
|
365
|
+
if (pack !== null) {
|
|
366
|
+
const own = pack.items[name];
|
|
367
|
+
if (own !== undefined)
|
|
368
|
+
return { value: own, packId, via: 'item' };
|
|
369
|
+
if (groupId !== undefined) {
|
|
370
|
+
const byGroup = pack.groups[groupId];
|
|
371
|
+
if (byGroup !== undefined)
|
|
372
|
+
return { value: byGroup, packId, via: 'group' };
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
const top = defaultPack.items[name];
|
|
376
|
+
if (top !== undefined)
|
|
377
|
+
return { value: top, packId: DEFAULT_PACK_ID, via: 'item' };
|
|
378
|
+
if (groupId !== undefined) {
|
|
379
|
+
const byGroup = defaultPack.groups[groupId];
|
|
380
|
+
if (byGroup !== undefined)
|
|
381
|
+
return { value: byGroup, packId: DEFAULT_PACK_ID, via: 'group' };
|
|
382
|
+
}
|
|
383
|
+
return { value: true, packId: DEFAULT_PACK_ID, via: 'builtin' };
|
|
384
|
+
};
|
|
385
|
+
return { isOpen: (name) => decide(name).value, groupOf, decide };
|
|
386
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 每个主体的装配记账。
|
|
3
|
+
*
|
|
4
|
+
* 三个不变式住在这里:同一个 key 的上一轮落地没完就不开始下一轮;新一轮先释放
|
|
5
|
+
* 上一轮的资源;落地失败退回「没有资源」,而不是留下半份。调用方只提供「这一轮怎么
|
|
6
|
+
* 算、怎么落地」,并自己决定计划没变时就别调进来。
|
|
7
|
+
*/
|
|
8
|
+
export type Dispose = () => void;
|
|
9
|
+
export interface Reconciler {
|
|
10
|
+
/**
|
|
11
|
+
* 重算并落地。返回 true 表示这一轮的资源已生效(或本来就不需要资源)。
|
|
12
|
+
* 失败时返回 false:上一轮已被释放,此刻这个主体没有资源。
|
|
13
|
+
*/
|
|
14
|
+
apply(key: string, build: () => Dispose | null): Promise<boolean>;
|
|
15
|
+
/** 释放并忘记这个 key 的资源(主体销毁时用)。 */
|
|
16
|
+
release(key: string): void;
|
|
17
|
+
}
|
|
18
|
+
export declare function createReconciler(warn: (message: string) => void): Reconciler;
|
package/lib/reconcile.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export function createReconciler(warn) {
|
|
2
|
+
const disposers = new Map();
|
|
3
|
+
/** 每个 key 的串联队列:上一轮没完成时,新一轮排在它后面。 */
|
|
4
|
+
const queues = new Map();
|
|
5
|
+
/** 每个 key 的世代号:释放或新一轮开始都会加一,用来丢弃过期结果。 */
|
|
6
|
+
const epochs = new Map();
|
|
7
|
+
const drop = (key) => {
|
|
8
|
+
const dispose = disposers.get(key);
|
|
9
|
+
if (dispose === undefined)
|
|
10
|
+
return;
|
|
11
|
+
disposers.delete(key);
|
|
12
|
+
try {
|
|
13
|
+
dispose();
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
warn('释放上一个装配失败(' + key + '):' + String(error));
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
const release = (key) => {
|
|
20
|
+
epochs.set(key, (epochs.get(key) ?? 0) + 1);
|
|
21
|
+
drop(key);
|
|
22
|
+
};
|
|
23
|
+
const apply = (key, build) => {
|
|
24
|
+
const previous = queues.get(key) ?? Promise.resolve(true);
|
|
25
|
+
const epoch = (epochs.get(key) ?? 0) + 1;
|
|
26
|
+
epochs.set(key, epoch);
|
|
27
|
+
const task = previous.then(async () => {
|
|
28
|
+
// 排队期间被 release(主体销毁)或被更新的一次顶掉:这一轮不落地,也不动资源。
|
|
29
|
+
if (epochs.get(key) !== epoch)
|
|
30
|
+
return false;
|
|
31
|
+
drop(key);
|
|
32
|
+
let dispose;
|
|
33
|
+
try {
|
|
34
|
+
dispose = build();
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
warn('装配失败(' + key + '):' + String(error));
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
// 落地期间被 release 或又被要求重算:这一份资源不该留下。
|
|
41
|
+
if (epochs.get(key) !== epoch) {
|
|
42
|
+
if (dispose !== null) {
|
|
43
|
+
try {
|
|
44
|
+
dispose();
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// 已过期,释放失败也无处可去。
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
if (dispose !== null)
|
|
53
|
+
disposers.set(key, dispose);
|
|
54
|
+
return true;
|
|
55
|
+
});
|
|
56
|
+
queues.set(key, task);
|
|
57
|
+
return task;
|
|
58
|
+
};
|
|
59
|
+
return { apply, release };
|
|
60
|
+
}
|