@tbox.cn/app-toolkit 0.3.0 → 0.4.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/dist/index.d.ts +91 -9
- package/dist/index.js +9 -8
- package/package.json +2 -2
- package/src/assembly/walk-manifests.ts +95 -28
- package/src/core/env-expansion.ts +14 -12
- package/src/core/module-schema.ts +3 -0
- package/src/dto.ts +13 -0
- package/src/index.ts +4 -0
- package/src/integrations/evaluate-app.ts +41 -0
- package/src/integrations/prune-bindings.ts +186 -0
- package/src/views/app.ts +6 -2
- package/src/views/context.ts +20 -0
- package/src/views/modules.ts +25 -13
- package/src/views/providers.ts +13 -1
- package/src/views/service-detail.ts +5 -3
- package/tests/demo-app.ts +27 -3
- package/tests/dev-manifest-catalog.test.ts +81 -1
- package/tests/module-schema.test.ts +15 -0
- package/tests/vendor-schema-keywords.test.ts +12 -0
- package/tests/views.test.ts +159 -3
|
@@ -15,9 +15,15 @@ import { readAppManifestFull, type NpmModuleEntry, type AppManifestFull } from '
|
|
|
15
15
|
* 3. serviceSchemas:per-service schema 复合键表——键 `${provider}\u0000${service}`(H2,同一服务
|
|
16
16
|
* 多厂商供给各自 schema 零碰撞);多 impl 同 (provider, service) 异 schema → 声明序首个
|
|
17
17
|
* 4. vocabulary:所有携带 service-slots.json 的已装契约包该文件 `.slots` 键并集(fs 直读、
|
|
18
|
-
* `$comment`
|
|
19
|
-
*
|
|
20
|
-
*
|
|
18
|
+
* `$comment` 忽略;探测三跳 = ① <候选>/node_modules/@tbox.cn/<pkg>/service-slots.json(包根)
|
|
19
|
+
* ② 同包 src/service-slots.json(发布包 src 附带 / 模板 shim 链接)
|
|
20
|
+
* ③ 候选自身 {service-slots.json, src/service-slots.json}(local/codegen 全量副本——
|
|
21
|
+
* mall 生成应用主形态,不依赖 install);零缓存枚举,与 manifest 探测同机制;
|
|
22
|
+
* 多行业 contracts-* 天然扩展 F8);缺席 → [](退化 demanded ∪ configured)
|
|
23
|
+
* 5. slotMeta:词汇 meta 节(per-service {title,description} 展示元数据——消费端兜底链
|
|
24
|
+
* 词汇 > demand > id,views/context resolveServiceDisplay 单源);多包发现序首声明胜出
|
|
25
|
+
* (与 catalog addSlots 同语义);contracts-mall 词汇锁保证 mall 域全量覆盖;缺席 → {}
|
|
26
|
+
* 6. domainKeyIssues:跨模块域键冲突 issue(两模块派生同一 domains 键——P13)
|
|
21
27
|
*
|
|
22
28
|
* **约束(D26;FX-3/F7 边界)**:目录枚举零缓存——readdir / node_modules 候选探测 / `.tbox/app.json` 读取 /
|
|
23
29
|
* dev-manifest 读取每次执行(枚举输入保持 fresh——agent 装卸模块下一笔即见);manifest/service-slots
|
|
@@ -35,10 +41,48 @@ export interface WalkManifestsResult {
|
|
|
35
41
|
serviceSchemas: Record<string, { configSchema?: string; credentialSchema?: string; packageDir: string }>;
|
|
36
42
|
/** 服务词汇 = 所有携带 service-slots.json 的已装契约包 `.slots` 键并集(缺席 → []) */
|
|
37
43
|
vocabulary: string[];
|
|
44
|
+
/** 词汇 meta 节:per-service {title,description} 展示元数据(多包首声明胜出;缺席 → {}) */
|
|
45
|
+
slotMeta: Record<string, SlotMeta>;
|
|
38
46
|
/** 跨模块域键冲突(P13——两模块的 service 集派生同一 domains 键) */
|
|
39
47
|
domainKeyIssues: Array<{ severity: 'warning'; code: 'DOMAIN_KEY_CONFLICT'; message: string; module?: string }>;
|
|
40
48
|
}
|
|
41
49
|
|
|
50
|
+
/** 槽位展示元数据(service-slots.json meta 节元素——宽松提取,字段皆可选) */
|
|
51
|
+
export interface SlotMeta {
|
|
52
|
+
title?: string;
|
|
53
|
+
description?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 词汇文件单次解析产物(slots 与 meta 同源同读——防双读漂移) */
|
|
57
|
+
interface SlotsFile {
|
|
58
|
+
slots: string[];
|
|
59
|
+
meta: Record<string, SlotMeta>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 词汇文件解析(宽松:坏文件/缺 slots 数组 → null;meta 非对象条目/无有效字段跳过) */
|
|
63
|
+
function parseSlotsFile(raw: string): SlotsFile | null {
|
|
64
|
+
try {
|
|
65
|
+
const parsed = JSON.parse(raw) as { slots?: unknown; meta?: unknown };
|
|
66
|
+
if (!Array.isArray(parsed.slots)) return null;
|
|
67
|
+
const slots = parsed.slots.filter((s): s is string => typeof s === 'string');
|
|
68
|
+
const meta: Record<string, SlotMeta> = {};
|
|
69
|
+
if (parsed.meta && typeof parsed.meta === 'object' && !Array.isArray(parsed.meta)) {
|
|
70
|
+
for (const [svc, entry] of Object.entries(parsed.meta as Record<string, unknown>)) {
|
|
71
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
|
|
72
|
+
const { title, description } = entry as { title?: unknown; description?: unknown };
|
|
73
|
+
const m: SlotMeta = {};
|
|
74
|
+
if (typeof title === 'string' && title.length > 0) m.title = title;
|
|
75
|
+
if (typeof description === 'string' && description.length > 0) m.description = description;
|
|
76
|
+
if (m.title !== undefined || m.description !== undefined) meta[svc] = m;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { slots, meta };
|
|
80
|
+
} catch {
|
|
81
|
+
/* 宽松:坏文件跳过 */
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
42
86
|
export interface WalkModuleInfo {
|
|
43
87
|
/** install id(packages/ 目录名 / npmModules entry.id) */
|
|
44
88
|
id: string;
|
|
@@ -49,12 +93,17 @@ export interface WalkModuleInfo {
|
|
|
49
93
|
descriptor: ModuleDescriptor;
|
|
50
94
|
}
|
|
51
95
|
|
|
52
|
-
/** 富形态供给条目:per-service {implementation, credentialType} 恒数组保真(H1
|
|
96
|
+
/** 富形态供给条目:per-service {implementation, credentialType} 恒数组保真(H1);
|
|
97
|
+
* title/description = 厂商展示元数据(strict manifest descriptor 透传——多包同厂商
|
|
98
|
+
* 首声明胜出,与 owner 同语义;B1 walk 层传播) */
|
|
53
99
|
export interface WalkProviderEntry {
|
|
54
100
|
provider: string;
|
|
55
101
|
implementation: string;
|
|
56
102
|
credentialType: string;
|
|
57
103
|
owner: string;
|
|
104
|
+
/** 厂商展示名(manifest 模块 meta——多包首声明;缺席 = undefined) */
|
|
105
|
+
title?: string;
|
|
106
|
+
description?: string;
|
|
58
107
|
local?: boolean;
|
|
59
108
|
configSchema?: string;
|
|
60
109
|
credentialSchema?: string;
|
|
@@ -174,10 +223,15 @@ function readDevManifestRedirects(appDir: string): DevManifestRedirect[] {
|
|
|
174
223
|
}
|
|
175
224
|
}
|
|
176
225
|
|
|
177
|
-
/**
|
|
178
|
-
*
|
|
179
|
-
|
|
180
|
-
|
|
226
|
+
/** 契约包发现链(探测三跳——词汇源真实布局全覆盖,B0):候选集(appDir、apps/*、packages/*)×
|
|
227
|
+
* ① node_modules/@tbox.cn/<pkg>/service-slots.json(包根——夹具/旧布局)
|
|
228
|
+
* ② 同包 src/service-slots.json(发布包 src 随 files 附带 / 模板 shim 链接到仓库源 src/)
|
|
229
|
+
* ③ 候选自身 {service-slots.json, src/service-slots.json}(local/codegen 全量副本——mall 生成应用
|
|
230
|
+
* 主形态,stage rewriteExportsToSrc 展开后永驻 packages/*,不依赖 install)
|
|
231
|
+
* 同包双在场(异常态)root 优先。slots 与 meta 单次解析同产(目录枚举零缓存,F8;
|
|
232
|
+
* FX-3:文件**内容**经 file-cache)。 */
|
|
233
|
+
function discoverContractPackages(appDir: string, cache?: FileCache): Array<{ dir: string; slots: string[]; meta: Record<string, SlotMeta> }> {
|
|
234
|
+
const found: Array<{ dir: string; slots: string[]; meta: Record<string, SlotMeta> }> = [];
|
|
181
235
|
const candidates: Array<string> = [appDir];
|
|
182
236
|
for (const sub of ['apps', 'packages']) {
|
|
183
237
|
const parent = join(appDir, sub);
|
|
@@ -191,7 +245,12 @@ function discoverContractPackages(appDir: string, cache?: FileCache): Array<{ di
|
|
|
191
245
|
}
|
|
192
246
|
}
|
|
193
247
|
const seenRoots = new Set<string>();
|
|
248
|
+
const addDiscovered = (dir: string, file: string): void => {
|
|
249
|
+
const parsed = cache ? cache.read(file, parseSlotsFile) : parseSlotsFile(readFileSync(file, 'utf8'));
|
|
250
|
+
if (parsed !== null && parsed !== undefined) found.push({ dir, slots: parsed.slots, meta: parsed.meta });
|
|
251
|
+
};
|
|
194
252
|
for (const dir of candidates) {
|
|
253
|
+
// 跳 1/2:node_modules 位(包根 → src——显式安装真源优先)
|
|
195
254
|
const scopeDir = join(dir, 'node_modules', '@tbox.cn');
|
|
196
255
|
if (!existsSync(scopeDir)) continue;
|
|
197
256
|
let names: string[] = [];
|
|
@@ -204,19 +263,16 @@ function discoverContractPackages(appDir: string, cache?: FileCache): Array<{ di
|
|
|
204
263
|
const pkgDir = join(scopeDir, name);
|
|
205
264
|
if (seenRoots.has(pkgDir)) continue;
|
|
206
265
|
seenRoots.add(pkgDir);
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
};
|
|
218
|
-
const slots = cache ? cache.read(slotsFile, parseSlots) : parseSlots(readFileSync(slotsFile, 'utf8'));
|
|
219
|
-
if (slots !== null && slots !== undefined) found.push({ dir: pkgDir, slots });
|
|
266
|
+
const rootFile = join(pkgDir, 'service-slots.json');
|
|
267
|
+
const srcFile = join(pkgDir, 'src', 'service-slots.json');
|
|
268
|
+
if (existsSync(rootFile)) addDiscovered(pkgDir, rootFile);
|
|
269
|
+
else if (existsSync(srcFile)) addDiscovered(pkgDir, srcFile);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// 跳 3:候选自身(local/codegen 全量副本形态——后置于 nm 位:真源 > 副本,meta 首声明序随此)
|
|
273
|
+
for (const dir of candidates) {
|
|
274
|
+
for (const file of [join(dir, 'service-slots.json'), join(dir, 'src', 'service-slots.json')]) {
|
|
275
|
+
if (existsSync(file)) addDiscovered(dir, file);
|
|
220
276
|
}
|
|
221
277
|
}
|
|
222
278
|
return found;
|
|
@@ -232,7 +288,7 @@ export function walkManifests(appDir: string, cache?: FileCache): WalkManifestsR
|
|
|
232
288
|
/** 域键 → 首个声明模块(P13 冲突检测) */
|
|
233
289
|
const domainOwners = new Map<string, string>();
|
|
234
290
|
|
|
235
|
-
const addSlots = (slots: readonly ProviderSlotContrib[], owner: string, packageDir: string): void => {
|
|
291
|
+
const addSlots = (slots: readonly ProviderSlotContrib[], owner: string, packageDir: string, modMeta?: SlotMeta): void => {
|
|
236
292
|
for (const slot of slots) {
|
|
237
293
|
// 富形态:per-service {implementation, credentialType} 保真(同厂商多 impl 合法——声明序)
|
|
238
294
|
const vendorSlots = (catalog.providers[slot.provider] ??= {});
|
|
@@ -245,6 +301,8 @@ export function walkManifests(appDir: string, cache?: FileCache): WalkManifestsR
|
|
|
245
301
|
implementation: slot.implementation,
|
|
246
302
|
credentialType: slot.credentialType,
|
|
247
303
|
owner,
|
|
304
|
+
...(modMeta?.title ? { title: modMeta.title } : {}),
|
|
305
|
+
...(modMeta?.description ? { description: modMeta.description } : {}),
|
|
248
306
|
...(slot.local ? { local: true } : {}),
|
|
249
307
|
...(slot.configSchema ? { configSchema: slot.configSchema } : {}),
|
|
250
308
|
...(slot.credentialSchema ? { credentialSchema: slot.credentialSchema } : {}),
|
|
@@ -268,6 +326,10 @@ export function walkManifests(appDir: string, cache?: FileCache): WalkManifestsR
|
|
|
268
326
|
}
|
|
269
327
|
};
|
|
270
328
|
|
|
329
|
+
/** 厂商展示元数据(strict manifest descriptor 透传——loose 形态无 descriptor → undefined) */
|
|
330
|
+
const modMetaOf = (manifest: ReadManifestResult): SlotMeta | undefined =>
|
|
331
|
+
manifest.kind === 'strict' ? { title: manifest.descriptor.title, description: manifest.descriptor.description } : undefined;
|
|
332
|
+
|
|
271
333
|
const addServiceNeed = (
|
|
272
334
|
need: { service: string; optional?: boolean; defaults?: Array<{ provider: string; implementation: string }>; title?: string; description?: string },
|
|
273
335
|
owner: string,
|
|
@@ -324,7 +386,7 @@ export function walkManifests(appDir: string, cache?: FileCache): WalkManifestsR
|
|
|
324
386
|
modules.push({ id: entry.id, dir, pkg: entry.mode === 'sdk' ? entry.package : `@app/${entry.id}`, descriptor: manifest.descriptor });
|
|
325
387
|
}
|
|
326
388
|
const slots = contributes.providers?.slots ?? [];
|
|
327
|
-
if (slots.length) addSlots(slots, entry.package, dir);
|
|
389
|
+
if (slots.length) addSlots(slots, entry.package, dir, modMetaOf(manifest));
|
|
328
390
|
const needs = contributes.services ?? [];
|
|
329
391
|
if (needs.length) {
|
|
330
392
|
for (const need of needs) {
|
|
@@ -340,14 +402,14 @@ export function walkManifests(appDir: string, cache?: FileCache): WalkManifestsR
|
|
|
340
402
|
if (!manifest?.name || seen.has(manifest.name)) continue;
|
|
341
403
|
seen.add(manifest.name);
|
|
342
404
|
const slots = manifest.contributes.providers?.slots ?? [];
|
|
343
|
-
if (slots.length) addSlots(slots, pkg, dir);
|
|
405
|
+
if (slots.length) addSlots(slots, pkg, dir, modMetaOf(manifest));
|
|
344
406
|
const needs = manifest.contributes.services ?? [];
|
|
345
407
|
if (needs.length) {
|
|
346
408
|
for (const need of needs) addServiceNeed(need, pkg);
|
|
347
409
|
}
|
|
348
410
|
}
|
|
349
411
|
|
|
350
|
-
// 通道 2:packages/* 约定发现(本地模块——npmModules
|
|
412
|
+
// 通道 2:packages/* 约定发现(本地模块——npmModules 未登记形态,如手工放入的本地模块)
|
|
351
413
|
const packagesDir = join(appDir, 'packages');
|
|
352
414
|
if (existsSync(packagesDir)) {
|
|
353
415
|
let entries: string[] = [];
|
|
@@ -369,7 +431,7 @@ export function walkManifests(appDir: string, cache?: FileCache): WalkManifestsR
|
|
|
369
431
|
modules.push({ id: modName, dir, pkg: `@app/${modName}`, descriptor: manifest.descriptor });
|
|
370
432
|
}
|
|
371
433
|
const slots = contributes.providers?.slots ?? [];
|
|
372
|
-
if (slots.length) addSlots(slots, `@app/${modName}`, dir);
|
|
434
|
+
if (slots.length) addSlots(slots, `@app/${modName}`, dir, modMetaOf(manifest));
|
|
373
435
|
const needs = contributes.services ?? [];
|
|
374
436
|
if (needs.length) {
|
|
375
437
|
for (const need of needs) {
|
|
@@ -379,13 +441,18 @@ export function walkManifests(appDir: string, cache?: FileCache): WalkManifestsR
|
|
|
379
441
|
}
|
|
380
442
|
}
|
|
381
443
|
|
|
382
|
-
// 产物 4
|
|
444
|
+
// 产物 4/5:服务词汇 + 词汇 meta(契约包 service-slots.json 并集——零缓存枚举;
|
|
445
|
+
// meta 多包发现序首声明胜出,与 catalog addSlots 同语义)
|
|
383
446
|
const vocabulary = new Set<string>();
|
|
447
|
+
const slotMeta: Record<string, SlotMeta> = {};
|
|
384
448
|
for (const pkg of discoverContractPackages(appDir, cache)) {
|
|
385
449
|
for (const s of pkg.slots) vocabulary.add(s);
|
|
450
|
+
for (const [svc, m] of Object.entries(pkg.meta)) {
|
|
451
|
+
if (slotMeta[svc] === undefined) slotMeta[svc] = m;
|
|
452
|
+
}
|
|
386
453
|
}
|
|
387
454
|
|
|
388
|
-
return { modules, catalog, serviceSchemas, vocabulary: [...vocabulary].sort(), domainKeyIssues };
|
|
455
|
+
return { modules, catalog, serviceSchemas, vocabulary: [...vocabulary].sort(), slotMeta, domainKeyIssues };
|
|
389
456
|
}
|
|
390
457
|
|
|
391
458
|
// ── CLI collectDeclared 迁入(C5——声明集合 + 双端入口探测 + declaration.moduleId 提取)──
|
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 配置 JSON 环境变量占位展开(纯数据形态——自 app-sdk env-expansion.ts 迁入改造)。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* ${NAME}
|
|
6
|
-
*
|
|
4
|
+
* 语法(语义真源 = 本文件 + 共享向量 test-fixtures/env-expansion-cases.json 防漂移):
|
|
5
|
+
* ${NAME} 占位引用;NAME 限定 [A-Z][A-Z0-9_]*(UPPER_SNAKE,最小化与正文 `${...}` 误碰)
|
|
6
|
+
* ${NAME:def} POSIX 风格缺省值:NAME 未设置时用 def(字面量,不二次展开);设置时空串也算「已设置」
|
|
7
|
+
* $${NAME} 转义为字面量 ${NAME}(原样保留一次 $)
|
|
7
8
|
*
|
|
8
9
|
* 语义(评审 N1 钉死,壳层各自包装 throw/report):
|
|
9
10
|
* - 单遍非递归:env 值内若含 `${OTHER}` 不被二次展开(注入防线);String.replace 回调形态天然单遍。
|
|
11
|
+
* 默认值内含 `${...}` 同样不展开(POSIX 同限);默认值含 `}` 不支持(同 POSIX 同限——正则 [^}] 截断)。
|
|
10
12
|
* - 值经 JSON 字符串转义注入;不做数值/布尔强制转换。
|
|
11
|
-
* - **本版为纯数据返回形态** `{ text?: string; missing: string[] }
|
|
13
|
+
* - **本版为纯数据返回形态** `{ text?: string; missing: string[] }`——缺变量(无缺省值时)不 throw,
|
|
12
14
|
* 由壳层(CLI doctor 壳 / app-sdk 桥 throw 壳 / 沙箱宿主)决定 fail-fast 还是降级:
|
|
13
15
|
* SDK 桥壳 missing 非空即 throw(错误文案逐字等价 SDK 旧版);CLI 壳同款。
|
|
14
16
|
* - 无 ${ 的文本快速路径:text 原文返回,零替换零开销。
|
|
15
17
|
*/
|
|
16
18
|
|
|
17
|
-
/** 占位匹配:$ 或 $$ 后接 {
|
|
18
|
-
const ENV_VAR_RE = /\$\$?\{([A-Z][A-Z0-9_]*)
|
|
19
|
+
/** 占位匹配:$ 或 $$ 后接 {NAME} 或 {NAME:default}($$ 形态同匹配,展开期判首字符去留) */
|
|
20
|
+
const ENV_VAR_RE = /\$\$?\{([A-Z][A-Z0-9_]*)(?::-([^}]*))?\}/g;
|
|
19
21
|
|
|
20
22
|
/** JSON 字符串字面量转义(值 → 可直接置于 "..." 内的安全文本)——
|
|
21
23
|
* 基于 JSON.stringify 切片:引号/反斜杠/控制字符(\b/\n/\r/\t/\f)转义语义与 JSON 精确一致 */
|
|
@@ -37,14 +39,14 @@ export function expandEnvVars(
|
|
|
37
39
|
): EnvExpansionResult {
|
|
38
40
|
const missing: string[] = [];
|
|
39
41
|
// 单遍替换:回调返回值不回扫(非递归语义;$${} 转义在回调内消去一个 $)
|
|
40
|
-
const out = text.replace(ENV_VAR_RE, (raw, name: string) => {
|
|
42
|
+
const out = text.replace(ENV_VAR_RE, (raw, name: string, fallback?: string) => {
|
|
41
43
|
if (raw.startsWith('$$')) return raw.slice(1);
|
|
42
44
|
const v = env[name];
|
|
43
|
-
if (v
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
return
|
|
45
|
+
if (v !== undefined) return jsonEscape(v);
|
|
46
|
+
// ${NAME:def}:NAME 未设置 → 缺省值(字面量;空串默认 = 显式空值语义)
|
|
47
|
+
if (fallback !== undefined) return jsonEscape(fallback);
|
|
48
|
+
missing.push(name);
|
|
49
|
+
return raw; // 占位保留(纯数据形态:missing 上报,不 throw)
|
|
48
50
|
});
|
|
49
51
|
if (missing.length > 0) return { missing };
|
|
50
52
|
return { text: out, missing: [] };
|
|
@@ -78,6 +78,9 @@ export const moduleDescriptorSchema = z
|
|
|
78
78
|
.object({
|
|
79
79
|
schemaVersion: z.number().default(1),
|
|
80
80
|
name: z.string(),
|
|
81
|
+
// 模块级 meta additive(B1):展示名/描述——回退归视图层(title ?? name / ''),schema 不灌值
|
|
82
|
+
title: z.string().optional(),
|
|
83
|
+
description: z.string().optional(),
|
|
81
84
|
version: z.string().default('0.0.0'),
|
|
82
85
|
kind: z.enum(['platform', 'business', 'third-party']).default('business'),
|
|
83
86
|
risk: z
|
package/src/dto.ts
CHANGED
|
@@ -50,6 +50,10 @@ export interface ServiceDemand {
|
|
|
50
50
|
|
|
51
51
|
/** GET /modules/:module(模块静态一站式 · PUT 读对偶) */
|
|
52
52
|
export interface ModuleDetailView {
|
|
53
|
+
/** B1 additive:模块 meta 展示名(manifest title;视图层兜底 name——恒有值) */
|
|
54
|
+
title: string;
|
|
55
|
+
/** B1 additive:模块 meta 描述(manifest description;兜底 '') */
|
|
56
|
+
description: string;
|
|
53
57
|
/** v4.4 D34:demand 服务首段去重(排序;1..N;零域 = []) */
|
|
54
58
|
domainKeys: string[];
|
|
55
59
|
/** domains[换算键] 节点原值;未配置 = null;v4.4 D34:仅单域(writable)时在场——多域/零域/共享键无单键落点 */
|
|
@@ -287,6 +291,9 @@ export interface ProviderEntry {
|
|
|
287
291
|
local: boolean;
|
|
288
292
|
/** 供给声明来源包名 */
|
|
289
293
|
owner: string;
|
|
294
|
+
/** B1 additive:厂商展示名(manifest 模块 meta——多包首声明;缺席 = undefined) */
|
|
295
|
+
title?: string;
|
|
296
|
+
description?: string;
|
|
290
297
|
}
|
|
291
298
|
|
|
292
299
|
/** GET /providers/:provider:供给足迹 */
|
|
@@ -294,12 +301,18 @@ export interface ProviderDetail {
|
|
|
294
301
|
provider: string;
|
|
295
302
|
local: boolean;
|
|
296
303
|
owner: string;
|
|
304
|
+
/** B1 additive:厂商展示名(同 ProviderEntry——首声明透传) */
|
|
305
|
+
title?: string;
|
|
306
|
+
description?: string;
|
|
297
307
|
/** per (provider, service) 保真,同厂商异构合法;★ implementations 恒数组(H1——
|
|
298
308
|
* 多元素 = 升级窗口,UI 出 impl 选择器,PUT 显式钉版) */
|
|
299
309
|
services: Record<
|
|
300
310
|
string,
|
|
301
311
|
{
|
|
302
312
|
implementations: Array<{ implementation: string; credentialType: string }>;
|
|
313
|
+
/** B1 additive:服务展示名(resolveServiceDisplay——词汇 > demand > id;恒发) */
|
|
314
|
+
title?: string;
|
|
315
|
+
description?: string;
|
|
303
316
|
/** v4.4 F5:include=integrationSchemas 切换态批量(纯 config——凭据结构归 /credential-types) */
|
|
304
317
|
integrationSchemas?: { configSchema: object | null };
|
|
305
318
|
}
|
package/src/index.ts
CHANGED
|
@@ -56,6 +56,10 @@ export type {
|
|
|
56
56
|
// ── 谓词装配单源(FX-2b/F1:CLI doctor 规则与 toolkit 工厂同装配面——双通道 walkManifests)──
|
|
57
57
|
export { buildPredicateAssembly, refToEnvKey } from './integrations/predicate-io.js';
|
|
58
58
|
export type { PredicateAssembly, PredicateIoInputs } from './integrations/predicate-io.js';
|
|
59
|
+
// ── 应用级求值单源(cli-remove-prune-and-new:doctor integration-services 与 prune 同一求值)──
|
|
60
|
+
export { evaluateAppIntegrationIssues } from './integrations/evaluate-app.js';
|
|
61
|
+
export { pruneIntegrationBindings } from './integrations/prune-bindings.js';
|
|
62
|
+
export type { PruneResult } from './integrations/prune-bindings.js';
|
|
59
63
|
|
|
60
64
|
// ── assembly 层(目录知识——walkManifests 四产物 + catalog 双形态 + 组合单体装配)──
|
|
61
65
|
export { walkManifests } from './assembly/walk-manifests.js';
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ResolvedContracts } from '../core/contracts-resolver.js';
|
|
2
|
+
import { walkManifests } from '../assembly/walk-manifests.js';
|
|
3
|
+
import { loadProviderCatalog } from '../assembly/provider-catalog.js';
|
|
4
|
+
import { buildPredicateAssembly } from './predicate-io.js';
|
|
5
|
+
import {
|
|
6
|
+
evaluateIntegrationServices,
|
|
7
|
+
type IntegrationServiceIssue,
|
|
8
|
+
type PredicateContracts,
|
|
9
|
+
type SupplyLookup,
|
|
10
|
+
} from './predicate.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 应用级 integrations 求值(cli-remove-prune-and-new D2.4):doctor integration-services 规则内
|
|
14
|
+
* "装配+求值"序列的单源抽取(FX-2b/F1 轨迹延续——此前 buildPredicateAssembly 已单源,本函数
|
|
15
|
+
* 将 walk→supplies→assembly→evaluate 全序列收口)。
|
|
16
|
+
*
|
|
17
|
+
* 消费方:
|
|
18
|
+
* - doctor integration-services 规则(外壳保留:stale 防线/文件读取/env 展开/形状守卫/分级投射)
|
|
19
|
+
* - pruneIntegrationBindings(prune 判据 ≡ doctor 判据的构造性同源载体)
|
|
20
|
+
*
|
|
21
|
+
* 契约:调用方须先 `resolveContractsForApp` 并确保 `strictValidation`(本函数不再分级门控——
|
|
22
|
+
* doctor 的 warning/error 升级指引与 prune 的 skip 语义分属两消费方,不收口)。
|
|
23
|
+
*/
|
|
24
|
+
export async function evaluateAppIntegrationIssues(
|
|
25
|
+
appDir: string,
|
|
26
|
+
integrations: Record<string, unknown>,
|
|
27
|
+
contracts: ResolvedContracts,
|
|
28
|
+
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
29
|
+
): Promise<IntegrationServiceIssue[]> {
|
|
30
|
+
const module = contracts.module as PredicateContracts;
|
|
31
|
+
const walk = walkManifests(appDir);
|
|
32
|
+
const supplies: SupplyLookup = module.buildSupplyLookup(loadProviderCatalog(appDir) as never);
|
|
33
|
+
const assembly = buildPredicateAssembly(appDir, walk, integrations, env);
|
|
34
|
+
|
|
35
|
+
return evaluateIntegrationServices({
|
|
36
|
+
contracts: module,
|
|
37
|
+
integrations: integrations as never,
|
|
38
|
+
supplies,
|
|
39
|
+
...assembly,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { existsSync, rmSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import type { ResolvedContracts } from '../core/contracts-resolver.js';
|
|
4
|
+
import { resolveContractsForApp } from '../core/contracts-resolver.js';
|
|
5
|
+
import { expandEnvVars } from '../core/env-expansion.js';
|
|
6
|
+
import { writeAtomic } from '../core/fskit.js';
|
|
7
|
+
import { walkManifests } from '../assembly/walk-manifests.js';
|
|
8
|
+
import { readIntegrationsConfig } from './read.js';
|
|
9
|
+
import { domainOfService } from './domain-binding.js';
|
|
10
|
+
import { isControlledInlineProvider } from './predicate.js';
|
|
11
|
+
import { reconcileOrphanCredentials } from './credentials.js';
|
|
12
|
+
import { evaluateAppIntegrationIssues } from './evaluate-app.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* integrations.json 死绑定清理(cli-remove-prune-and-new D2 核心件)。
|
|
16
|
+
*
|
|
17
|
+
* 不变式:**删除判据 ≡ doctor integration-services 报错判据(构造性同源)**——消费 doctor 同一
|
|
18
|
+
* 共享求值器 evaluateAppIntegrationIssues 的结构化 issue,code 白名单
|
|
19
|
+
* {VENDOR_NOT_INSTALLED, BINDING_UNRESOLVED} + 守卫(绑定引用链候选 provider ∈ installedVendors
|
|
20
|
+
* 即保留,排除活厂商错配)→ 删除域严格 = "供给载体消亡"。
|
|
21
|
+
*
|
|
22
|
+
* 由此推论(均已实证):需求侧孤儿绑定(demand 消失、供给在场)保留——doctor 判其合法;用户
|
|
23
|
+
* 预配置绑定受保护;一次 remove 自愈全文件历史死债;凭据类 issue(CREDENTIAL_FILE_MISSING 等)
|
|
24
|
+
* 刻意不进白名单,原样保留。
|
|
25
|
+
*
|
|
26
|
+
* 写侧三防线(镜像 write core):N1 归一化(空模块节点删 domains 键)→ parseIntegrationsConfig
|
|
27
|
+
* 严格校验(失败弃写)→ writeAtomic。凭据孤儿只报告不清理(D9——与 DELETE 管线现行为一致)。
|
|
28
|
+
* 全空(无任何存活绑定)→ 删文件——ensureMockBindings(缺席即生成)在重装供给时再生闭环。
|
|
29
|
+
*
|
|
30
|
+
* 最佳努力语义:谓词不可用(contracts 未安装/降级、env 缺失、求值 throw)→ skipped 返回 +
|
|
31
|
+
* 文件原样,绝不阻断 remove 本体。
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
export interface PruneResult {
|
|
35
|
+
/** 被清除的绑定节点描述(remove 日志行,如 'root.provider=mock' / 'services.member.account') */
|
|
36
|
+
removed: string[];
|
|
37
|
+
/** 孤儿凭据(D9 只报告不清理——reconcileOrphanCredentials 产物 message) */
|
|
38
|
+
orphanCredentials: string[];
|
|
39
|
+
/** 跳过原因(正常路径 undefined;not-configured = integrations.json 缺席,兼容态零动作) */
|
|
40
|
+
skipped?: 'predicate-unavailable' | 'validation-failed' | 'not-configured';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 宽松形状(谓词输入同族子集——doctor 读裸 JSON 先例,严格 zod 归模板 loader) */
|
|
44
|
+
interface PruneDoc {
|
|
45
|
+
provider?: string;
|
|
46
|
+
domains?: Record<string, { provider?: string; config?: Record<string, unknown> }>;
|
|
47
|
+
config?: Record<string, unknown>;
|
|
48
|
+
configByInstance?: Record<string, Record<string, unknown>>;
|
|
49
|
+
credentialRef?: string;
|
|
50
|
+
credentialRefByInstance?: Record<string, string>;
|
|
51
|
+
instances?: Array<{ id: string }>;
|
|
52
|
+
defaultInstanceId?: string | null;
|
|
53
|
+
modules?: Record<string, unknown>;
|
|
54
|
+
services?: Record<
|
|
55
|
+
string,
|
|
56
|
+
{
|
|
57
|
+
provider?: string | Record<string, unknown>;
|
|
58
|
+
implementation?: unknown;
|
|
59
|
+
config?: Record<string, unknown>;
|
|
60
|
+
credentialRef?: string;
|
|
61
|
+
instances?: Record<string, { enabled?: boolean; provider?: unknown; implementation?: unknown; credentialRef?: string }>;
|
|
62
|
+
}
|
|
63
|
+
>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 候选 provider 字符串提取(string 直取;受控内联对象取 .provider;其余 undefined) */
|
|
67
|
+
function providerOf(v: unknown): string | undefined {
|
|
68
|
+
if (typeof v === 'string') return v.length > 0 ? v : undefined;
|
|
69
|
+
if (v && typeof v === 'object' && isControlledInlineProvider(v)) {
|
|
70
|
+
const p = (v as { provider: string }).provider;
|
|
71
|
+
return p.length > 0 ? p : undefined;
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function pruneIntegrationBindings(appDir: string): Promise<PruneResult> {
|
|
77
|
+
const file = join(appDir, 'config', 'integrations.json');
|
|
78
|
+
const raw = readIntegrationsConfig(appDir);
|
|
79
|
+
if (raw === null) return { removed: [], orphanCredentials: [], skipped: 'not-configured' };
|
|
80
|
+
|
|
81
|
+
const contracts: ResolvedContracts = await resolveContractsForApp(appDir);
|
|
82
|
+
if (!contracts.strictValidation) {
|
|
83
|
+
return { removed: [], orphanCredentials: [], skipped: 'predicate-unavailable' };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// env 展开(tolerant——doctor 走 fail-fast 外壳,prune 缺变量即降级跳过)
|
|
87
|
+
let integrations: Record<string, unknown>;
|
|
88
|
+
try {
|
|
89
|
+
const expanded = expandEnvVars(JSON.stringify(raw), process.env);
|
|
90
|
+
if (expanded.missing.length > 0 || expanded.text === undefined) {
|
|
91
|
+
return { removed: [], orphanCredentials: [], skipped: 'predicate-unavailable' };
|
|
92
|
+
}
|
|
93
|
+
integrations = JSON.parse(expanded.text) as Record<string, unknown>;
|
|
94
|
+
} catch {
|
|
95
|
+
return { removed: [], orphanCredentials: [], skipped: 'predicate-unavailable' };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let issues;
|
|
99
|
+
try {
|
|
100
|
+
issues = await evaluateAppIntegrationIssues(appDir, integrations, contracts, process.env);
|
|
101
|
+
} catch {
|
|
102
|
+
return { removed: [], orphanCredentials: [], skipped: 'predicate-unavailable' };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const installedVendors = new Set(Object.keys(walkManifests(appDir).catalog.providers));
|
|
106
|
+
const next = structuredClone(raw) as PruneDoc;
|
|
107
|
+
next.services ??= {};
|
|
108
|
+
const removed: string[] = [];
|
|
109
|
+
|
|
110
|
+
for (const issue of issues) {
|
|
111
|
+
if (issue.severity !== 'error') continue;
|
|
112
|
+
if (issue.code === 'VENDOR_NOT_INSTALLED') {
|
|
113
|
+
if (issue.module) {
|
|
114
|
+
const d = next.domains?.[issue.module];
|
|
115
|
+
if (d?.provider) {
|
|
116
|
+
removed.push(`domains.${issue.module}.provider=${d.provider}`);
|
|
117
|
+
delete d.provider;
|
|
118
|
+
if (Object.keys(d).length === 0) delete next.domains![issue.module]; // N1
|
|
119
|
+
}
|
|
120
|
+
} else if (next.provider) {
|
|
121
|
+
removed.push(`root.provider=${next.provider}`);
|
|
122
|
+
delete next.provider;
|
|
123
|
+
}
|
|
124
|
+
} else if (issue.code === 'BINDING_UNRESOLVED' && issue.service) {
|
|
125
|
+
const s = next.services[issue.service];
|
|
126
|
+
if (!s) continue;
|
|
127
|
+
// 守卫:引用链任一候选 ∈ installedVendors → 活厂商错配,保留(doctor 报错语义不变)
|
|
128
|
+
const candidates = [
|
|
129
|
+
issue.instance ? providerOf(s.instances?.[issue.instance]?.provider) : undefined,
|
|
130
|
+
providerOf(s.provider),
|
|
131
|
+
providerOf(next.domains?.[domainOfService(issue.service)]?.provider),
|
|
132
|
+
providerOf(next.provider),
|
|
133
|
+
].filter((v): v is string => typeof v === 'string');
|
|
134
|
+
if (candidates.some((v) => installedVendors.has(v))) continue;
|
|
135
|
+
if (issue.instance) {
|
|
136
|
+
const inst = s.instances;
|
|
137
|
+
if (!inst || !(issue.instance in inst)) continue;
|
|
138
|
+
delete inst[issue.instance];
|
|
139
|
+
removed.push(`services.${issue.service}.instances.${issue.instance}`);
|
|
140
|
+
if (Object.keys(inst).length === 0) delete s.instances;
|
|
141
|
+
if (Object.keys(s).length === 0) delete next.services[issue.service];
|
|
142
|
+
} else {
|
|
143
|
+
delete next.services[issue.service];
|
|
144
|
+
removed.push(`services.${issue.service}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (Object.keys(next.services).length === 0) delete next.services;
|
|
149
|
+
if (removed.length === 0) return { removed, orphanCredentials: [] };
|
|
150
|
+
|
|
151
|
+
// 全空判定先行(**先于严格校验**——空文档 `{}` 缺必填 services 键必然被 zod 拒;删文件免校验)
|
|
152
|
+
const empty =
|
|
153
|
+
!next.provider &&
|
|
154
|
+
Object.keys(next.domains ?? {}).length === 0 &&
|
|
155
|
+
Object.keys(next.services ?? {}).length === 0 &&
|
|
156
|
+
Object.keys(next.config ?? {}).length === 0 &&
|
|
157
|
+
Object.keys(next.configByInstance ?? {}).length === 0 &&
|
|
158
|
+
!next.credentialRef &&
|
|
159
|
+
Object.keys(next.credentialRefByInstance ?? {}).length === 0 &&
|
|
160
|
+
(next.instances ?? []).length === 0 &&
|
|
161
|
+
next.defaultInstanceId == null &&
|
|
162
|
+
Object.keys(next.modules ?? {}).length === 0;
|
|
163
|
+
|
|
164
|
+
// ④ 严格校验(仅非空文档——services 为 schema 必填键,全服务剪除的存活文档补空 record 归一)
|
|
165
|
+
if (!empty) {
|
|
166
|
+
next.services ??= {};
|
|
167
|
+
if (typeof contracts.module.parseIntegrationsConfig === 'function') {
|
|
168
|
+
try {
|
|
169
|
+
contracts.module.parseIntegrationsConfig(next as never);
|
|
170
|
+
} catch {
|
|
171
|
+
return { removed: [], orphanCredentials: [], skipped: 'validation-failed' };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ⑤⑥ 写 + 孤儿报告(D9);空 → 删文件(ensureMockBindings 重装再生闭环)
|
|
177
|
+
let orphanCredentials: string[] = [];
|
|
178
|
+
if (empty) {
|
|
179
|
+
if (existsSync(file)) rmSync(file, { force: true });
|
|
180
|
+
} else {
|
|
181
|
+
writeAtomic(file, `${JSON.stringify(next, null, 2)}\n`);
|
|
182
|
+
}
|
|
183
|
+
orphanCredentials = reconcileOrphanCredentials(appDir).map((o) => o.message);
|
|
184
|
+
|
|
185
|
+
return { removed, orphanCredentials };
|
|
186
|
+
}
|
package/src/views/app.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { AppView, ServiceDemand } from '../dto.js';
|
|
2
2
|
import { APP_INTEGRATION_NODE_KEYS } from '../dto.js';
|
|
3
|
-
import { moduleOccupiedServices, type ViewSnapshot } from './context.js';
|
|
3
|
+
import { moduleOccupiedServices, resolveServiceDisplay, type ViewSnapshot } from './context.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* 应用静态投影(views/app;C4;FX-1c B10——root 节点投影修;v4.4 D29——services 双端点同型)。
|
|
@@ -14,9 +14,13 @@ import { moduleOccupiedServices, type ViewSnapshot } from './context.js';
|
|
|
14
14
|
|
|
15
15
|
export function loadAppView(snapshot: ViewSnapshot): AppView {
|
|
16
16
|
const occupied = moduleOccupiedServices(snapshot.walk);
|
|
17
|
+
// 平台条目展示名走链(B1):词汇 meta > demand meta > id——title 恒发(id 兜底内聚)
|
|
17
18
|
const services: ServiceDemand[] = snapshot.walk.vocabulary
|
|
18
19
|
.filter((s) => !occupied.has(s))
|
|
19
|
-
.map((s) =>
|
|
20
|
+
.map((s) => {
|
|
21
|
+
const display = resolveServiceDisplay(snapshot.walk, s);
|
|
22
|
+
return { service: s, title: display.title, ...(display.description !== undefined ? { description: display.description } : {}), required: false };
|
|
23
|
+
});
|
|
20
24
|
const config = snapshot.integrations;
|
|
21
25
|
let integration: AppView['integration'] = null;
|
|
22
26
|
if (config) {
|
package/src/views/context.ts
CHANGED
|
@@ -65,3 +65,23 @@ export function suppliedByVendors(catalog: WalkCatalog, service: string): string
|
|
|
65
65
|
}
|
|
66
66
|
return vendors.sort();
|
|
67
67
|
}
|
|
68
|
+
|
|
69
|
+
/** 服务展示名解析结果(title 恒有值——id 兜底内聚) */
|
|
70
|
+
export interface ServiceDisplay {
|
|
71
|
+
title: string;
|
|
72
|
+
description?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 服务展示名单源(B1):词汇 meta(slotMeta——contracts-mall 词汇锁保证 mall 域覆盖)
|
|
77
|
+
* > demand meta(catalog.services——域外槽兜底;首声明聚合)> service id。逐字段回落——
|
|
78
|
+
* 词汇条目仅含 description 时 demand title 仍可达(外部契约包部分 meta 前向容错)。
|
|
79
|
+
* 四视图唯一消费点(/modules/:m services、/app 平台条目、/services/:s、/providers/:p services)。
|
|
80
|
+
*/
|
|
81
|
+
export function resolveServiceDisplay(walk: WalkManifestsResult, service: string): ServiceDisplay {
|
|
82
|
+
const vocab = walk.slotMeta[service];
|
|
83
|
+
const demand = walk.catalog.services[service];
|
|
84
|
+
const title = vocab?.title ?? demand?.title ?? service;
|
|
85
|
+
const description = vocab?.description ?? demand?.description;
|
|
86
|
+
return { title, ...(description !== undefined ? { description } : {}) };
|
|
87
|
+
}
|