@tbox.cn/app-toolkit 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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +50 -0
  3. package/dist/chunk-VV3ERZXS.js +1 -0
  4. package/dist/contracts-resolver-QM4FBQQV.js +1 -0
  5. package/dist/index.d.ts +1909 -0
  6. package/dist/index.js +8 -0
  7. package/package.json +47 -0
  8. package/src/assembly/app-manifest.ts +132 -0
  9. package/src/assembly/provider-catalog.ts +167 -0
  10. package/src/assembly/walk-manifests.ts +461 -0
  11. package/src/core/contracts-expected.ts +31 -0
  12. package/src/core/contracts-resolver.ts +274 -0
  13. package/src/core/credential-format.ts +98 -0
  14. package/src/core/credential-mask.ts +19 -0
  15. package/src/core/env-expansion.ts +51 -0
  16. package/src/core/errors.ts +46 -0
  17. package/src/core/file-cache.ts +65 -0
  18. package/src/core/fskit.ts +21 -0
  19. package/src/core/module-schema.ts +139 -0
  20. package/src/dto.ts +298 -0
  21. package/src/factory.ts +297 -0
  22. package/src/index.ts +137 -0
  23. package/src/integrations/credentials.ts +153 -0
  24. package/src/integrations/mock-bindings.ts +55 -0
  25. package/src/integrations/predicate-io.ts +199 -0
  26. package/src/integrations/predicate.ts +598 -0
  27. package/src/integrations/read.ts +53 -0
  28. package/src/integrations/write.ts +448 -0
  29. package/src/views/app.ts +28 -0
  30. package/src/views/context.ts +70 -0
  31. package/src/views/modules.ts +48 -0
  32. package/src/views/providers.ts +165 -0
  33. package/src/views/service-detail.ts +32 -0
  34. package/src/views/service-resolutions.ts +323 -0
  35. package/tests/contracts-resolver.test.ts +203 -0
  36. package/tests/demo-app.ts +146 -0
  37. package/tests/dev-manifest-catalog.test.ts +114 -0
  38. package/tests/error-name-safety.test.ts +26 -0
  39. package/tests/file-cache.test.ts +242 -0
  40. package/tests/import-layers.test.ts +111 -0
  41. package/tests/module-schema.test.ts +84 -0
  42. package/tests/naming-alignment.test.ts +50 -0
  43. package/tests/views.test.ts +427 -0
  44. package/tests/write-core.test.ts +188 -0
  45. package/tsconfig.json +11 -0
  46. package/tsup.config.ts +29 -0
@@ -0,0 +1,598 @@
1
+ /**
2
+ * integration-services 谓词单源(integrations v4;自 CLI doctor/integration-services.ts 迁入——
3
+ * doctor 第 19 条与 toolkit 工厂 evaluateIntegrationServices 共用此函数,单源不变)。
4
+ * 纯函数、零副作用、零 I/O——contracts 经 input.contracts 注入(resolver 装配,版本解耦 D3)。
5
+ *
6
+ * 结构化输出(H7 v4.3):IntegrationIssue { severity, code, message, service?, module?, instance? }——
7
+ * 检查逻辑零改动(~24 处 push 机械加 code/定位字段;level→severity 字段同名随 D22 班车,
8
+ * severity 取值恒等旧 level——doctor 门控行为等价)。
9
+ * **code 枚举单源 = 本文件头映射表(additive-only;api.md §5 表与本表同步)**:
10
+ * CREDENTIAL_FILE_MISSING #1 error effective credentialRef 引用凭据文件不在场
11
+ * CREDENTIAL_TYPE_MISMATCH #2 error 凭据文件 .type 与绑定 credentialType 不一致
12
+ * SLOT_NOT_DECLARED #3 warning 服务槽无消费方声明(拼写/需求方未安装/旧槽位词汇)
13
+ * SLOT_NOT_CONFIGURED #4 warning 需求方缺口(服务槽未配置)
14
+ * INSTANCE_SUPPLY_UNMATCHED #5 error 绑定未命中已装供给(槽级/实例级覆盖)
15
+ * IMPL_WITHOUT_PROVIDER #5/#13 error impl-only(implementation 缺 provider 绑定组头)
16
+ * INLINE_PROVIDER_INVALID #5 error 内联厂商声明超受控形状(槽级/实例级)
17
+ * SCHEMA_FILE_MISSING #6 error credentialType schema 引用不存在
18
+ * DOMAIN_CONFIG_INVALID #7 error root/domains 死配置(键 ∉ 引用厂商 schema properties;
19
+ * api.md DOMAIN_CONFIG_UNUSED 行预留 warning 级,
20
+ * 现行为 error 恒等旧谓词——doctor 门控等价)
21
+ * INSTANCE_KEY_NOT_REGISTERED #8 error ByInstance 键/实例键 ∉ 注册表白名单
22
+ * REALM_SWITCH_HINT #9 warning 实现覆盖跨厂商(凭据域切换确认)
23
+ * WILDCARD_ALIGNMENT #10 warning 通配形态齐步提示
24
+ * MULTI_INSTANCE_NO_DEFAULT v4 warning 多实例无 defaultInstanceId(无 scope 访问落全局会话)
25
+ * VENDOR_NOT_INSTALLED #11 error root/domains 厂商未命中已装供给
26
+ * BINDING_UNRESOLVED #12 error 绑定不可解析(resolveEffectiveService 求值失败)
27
+ * DOMAIN_KEY_UNMATCHED #14 warning domains 键 ∉ 匹配域集(typo)
28
+ * RESOURCE_TYPE_UNKNOWN #15 error modules 资源节 type ∉ 词汇表
29
+ * RESOURCE_NOT_DECLARED #16 warning 资源键/资源 id ∉ 模块声明集
30
+ * ORPHANED_CREDENTIAL 写后对账 warning 凭据文件不再被引用(toolkit 写路径产出,非本谓词)
31
+ *
32
+ * 输入形状(调用方负责装载):
33
+ * - integrations:config/integrations.json 解析产物(宽松 Record 透传——严格 zod 校验归模板 loader)
34
+ * - suppliedProviders:已声明供给(`${vendor}\u0000${implementation}` 拼接键 → 槽集合;同源聚合)
35
+ * - demandedServices:已声明需求(service → { optional, owners })
36
+ * - supplies:派生查表(contracts buildSupplyLookup 产物;缺席 → #12 跳过,M3 降级兼容)
37
+ * - malls:可选实例 id 白名单(v4 源 = root instances 注册表 ids;缺席 → ByInstance 键检查跳过)
38
+ * - credentialSchemas:可选(credentialType → schema 文件引用路径;manifest 声明聚合)
39
+ * - configSchemas:可选(service/slot → schema 文件引用路径)
40
+ *
41
+ * 检查族(v3):
42
+ * #1 effective credentialRef → 档内凭据文件在场
43
+ * #2 凭据文件 .type 与绑定 credentialType 对齐(错配 = transport 期「未知认证类型」前置暴露;
44
+ * 继承绑定(槽级 provider 缺席)经 effective 求值取厂商——supplies 在场时)
45
+ * #5 实例 impl-only 禁止 + 覆盖命中供给(含槽级内联形状;provider-only = 派生位,合法)
46
+ * #6 manifest configSchema/credentialSchema 引用文件存在性(内联与 manifest 绑定同查)
47
+ * #7 root/domains 死配置:root.config/configByInstance/domains[d].config 键 ⊆ 引用厂商(并集)
48
+ * 各槽 schema properties——引用面 = root.provider ∪ domains.*.provider ∪ 槽级(terse 化防退化)
49
+ * #8 ByInstance/实例键 ∈ malls 白名单(v4:源 = root instances 注册表 ids——参考表在场时
50
+ * 激活;'**' 通配键豁免;注册表缺席 → 跳过;domains 键 = 域前缀非 instanceId,不适用)
51
+ * #9 实现覆盖跨厂商提示(realm switch——凭据域切换确认)
52
+ * #11 root/domains 厂商未命中已装供给(vendor 集合 = suppliedProviders 键前缀展开)
53
+ * #12 绑定可解析性:逐 (service, instanceId) 经 contracts resolveEffectiveService 求值
54
+ * (级联单源复用,谓词零级联实现),binding-unresolved → error(supplies 缺席 → 跳过)
55
+ * #13 槽级/实例级 impl-only(implementation 需伴随 provider——provider 是绑定组头;
56
+ * zod 之外的第二道防线——doctor 读裸 JSON)
57
+ * #14 domains 键 ∈ 匹配域集(已配置槽 ∪ 需求声明的 moduleDomainOf 并集;typo → warning)
58
+ * #15-#16 modules 资源节对账(v5):type ∉ RESOURCE_TYPE_IDS → error(裸 JSON 预启动反馈);
59
+ * 键 ∉ 已装模块 id 集 / 资源 id ∉ 模块 manifest contributes.resources 声明集(声明非空时)
60
+ * → warning(moduleResourceNeeds 缺席 → 需求面检查跳过,M3 降级先例)
61
+ */
62
+ import type { EvaluationExports } from '../core/contracts-expected.js';
63
+
64
+ /** contracts 注入面(resolver 装配产物运行时视图 = EvaluationExports 求值子集) */
65
+ export type PredicateContracts = Pick<
66
+ EvaluationExports,
67
+ 'resolveEffectiveService' | 'buildSupplyLookup' | 'moduleDomainOf' | 'normalizeInstances' | 'RESOURCE_TYPE_IDS'
68
+ >;
69
+
70
+ /** 派生查表形状(contracts buildSupplyLookup 产物;结构等价 TIER0 SupplyLookup——谓词零 contracts 静态依赖) */
71
+ export type SupplyLookup = Record<string, readonly string[]>;
72
+
73
+ export interface IntegrationServicePredicateInput {
74
+ integrations: {
75
+ provider?: string;
76
+ domains?: Record<string, { provider?: string; config?: Record<string, unknown> }>;
77
+ config?: Record<string, unknown>;
78
+ configByInstance?: Record<string, Record<string, unknown>>;
79
+ credentialRef?: string;
80
+ credentialRefByInstance?: Record<string, string>;
81
+ services: Record<
82
+ string,
83
+ {
84
+ provider?: string | Record<string, unknown>;
85
+ implementation?: string;
86
+ config?: Record<string, unknown>;
87
+ credentialRef?: string;
88
+ instances?: Record<string, { enabled?: boolean; provider?: unknown; implementation?: unknown; credentialRef?: string }>;
89
+ }
90
+ >;
91
+ /** v4 部署实例注册表(参考表——#8 白名单源 + 多实例无默认 warning) */
92
+ instances?: Array<{ id: string; name?: string; attributes?: Record<string, unknown> }>;
93
+ /** v4 默认实例(normalizeInstances 解析语义:显式 ?? 单候选唯一解) */
94
+ defaultInstanceId?: string | null;
95
+ /** v5 模块资源绑定(modules 节——宽松透传;严格 zod 归模板 loader) */
96
+ modules?: Record<string, { resources?: Record<string, { type?: unknown }> }>;
97
+ };
98
+ /** 已声明供给(`${vendor}\u0000${implementation}` → 槽集合;credentialType 供 #2/#6 绑定侧对齐) */
99
+ suppliedProviders: Record<string, { services: string[]; owner: string; credentialType?: string }>;
100
+ /** 已声明需求(service → { optional, owners }) */
101
+ demandedServices: Record<string, { optional: boolean; owners: string[] }>;
102
+ /** 派生查表(buildSupplyLookup 产物;缺席 → #12 跳过 + #2 继承绑定不查 credentialType) */
103
+ supplies?: SupplyLookup;
104
+ /** malls 白名单(deployments 控制面 extract;缺席 → ByInstance 键检查跳过) */
105
+ malls?: string[];
106
+ /** credentialType → schema 文件引用缺失(manifest 声明指向缺失文件;#6) */
107
+ credentialSchemaMissing?: Record<string, boolean>;
108
+ /** 厂商 → 槽 schema properties 键集(#7 用;调用方从 manifest configSchema 引用解析) */
109
+ vendorConfigKeys?: Record<string, Set<string>>;
110
+ /** 厂商 → configSchema 引用文件缺失(#7 跳过该厂商——无 schema 不能判死配置) */
111
+ vendorConfigSchemaMissing?: Record<string, boolean>;
112
+ /** 凭据文件缺失(#1:credentialRef stem → 档内 credentials/<stem>.json 在场;域中立注入) */
113
+ credentialFileMissing?: Record<string, boolean>;
114
+ /** 凭据文件 .type(stem → type;#2 对齐判定——文件无 type 则缺键,跳过) */
115
+ credentialFileType?: Record<string, string | undefined>;
116
+ /** #9:实现覆盖跨厂商提示位(CLI 侧组装) */
117
+ realmSwitchHints?: string[];
118
+ /** v5:已装模块 id → manifest contributes.resources 声明 id 集(值空集 = 无声明,资源级跳过;
119
+ * 缺席 = 无需求面信息——键未知/资源未声明检查跳过,type 检查恒跑) */
120
+ moduleResourceNeeds?: Record<string, ReadonlySet<string>>;
121
+ }
122
+
123
+ /** 结构化 issue(H7 v4.3):code 枚举单源 = 本文件头映射表(additive-only) */
124
+ export interface IntegrationServiceIssue {
125
+ severity: 'error' | 'warning';
126
+ code: string;
127
+ message: string;
128
+ service?: string;
129
+ module?: string;
130
+ instance?: string;
131
+ }
132
+
133
+ /** 内联厂商声明受控形状(v2:provider/credentialType/configSchema) */
134
+ export function isControlledInlineProvider(raw: unknown): boolean {
135
+ if (!raw || typeof raw !== 'object') return false;
136
+ const keys = Object.keys(raw);
137
+ const allowed = new Set(['provider', 'credentialType', 'configSchema']);
138
+ if (!keys.every((k) => allowed.has(k))) return false;
139
+ const p = raw as { provider?: unknown; credentialType?: unknown; configSchema?: unknown };
140
+ return (
141
+ typeof p.provider === 'string' && p.provider.length > 0 &&
142
+ typeof p.credentialType === 'string' && p.credentialType.length > 0 &&
143
+ (p.configSchema === undefined || typeof p.configSchema === 'string')
144
+ );
145
+ }
146
+
147
+ /** #8 单键判定(单源:谓词内部 noteMissingMallKey 与写路径 checkInstanceKeys 共用)。
148
+ * '*' 通配键豁免(contracts WILDCARD_INSTANCE_KEY;谓词零依赖内联)。 */
149
+ function instanceKeyIssue(key: string, where: string, mallsIndex: ReadonlySet<string>): IntegrationServiceIssue | null {
150
+ if (key === '*') return null;
151
+ if (mallsIndex.has(key)) return null;
152
+ return {
153
+ severity: 'error',
154
+ code: 'INSTANCE_KEY_NOT_REGISTERED',
155
+ instance: key,
156
+ message: `integrations: ${where} 键 ${key} 不在 malls 白名单内(${[...mallsIndex].join(', ')})`,
157
+ };
158
+ }
159
+
160
+ /** #8 检查族导出(FX-2b 单源):toolkit 写路径轻量复查(registryCheck)消费——
161
+ * 覆盖 configByInstance / credentialRefByInstance / services[s].instances 三键位;
162
+ * malls = root instances 注册表 ids(参考表缺席 → 消费方跳过,勿传空集)。 */
163
+ export function checkInstanceKeys(
164
+ integrations: {
165
+ configByInstance?: Record<string, unknown>;
166
+ credentialRefByInstance?: Record<string, unknown>;
167
+ services?: Record<string, { instances?: Record<string, unknown> }>;
168
+ },
169
+ malls: readonly string[],
170
+ ): IntegrationServiceIssue[] {
171
+ const mallsIndex = new Set(malls);
172
+ const out: IntegrationServiceIssue[] = [];
173
+ const note = (key: string, where: string): void => {
174
+ const issue = instanceKeyIssue(key, where, mallsIndex);
175
+ if (issue) out.push(issue);
176
+ };
177
+ for (const key of Object.keys(integrations.configByInstance ?? {})) note(key, 'configByInstance');
178
+ for (const key of Object.keys(integrations.credentialRefByInstance ?? {})) note(key, 'credentialRefByInstance');
179
+ for (const [serviceId, slot] of Object.entries(integrations.services ?? {})) {
180
+ for (const key of Object.keys(slot.instances ?? {})) note(key, `services.${serviceId}.instances`);
181
+ }
182
+ return out;
183
+ }
184
+
185
+ /** 谓词主体:integrations 配置 × catalog 声明 交叉校验(error/warning 分级;contracts 注入) */
186
+ export function evaluateIntegrationServices(
187
+ input: IntegrationServicePredicateInput & { contracts: PredicateContracts },
188
+ ): IntegrationServiceIssue[] {
189
+ const issues: IntegrationServiceIssue[] = [];
190
+ const contracts = input.contracts;
191
+ const { integrations, suppliedProviders, demandedServices, supplies, malls, credentialSchemaMissing, vendorConfigKeys, vendorConfigSchemaMissing, credentialFileMissing, credentialFileType, realmSwitchHints, moduleResourceNeeds } = input;
192
+
193
+ // #8 白名单索引(malls 缺席 → 跳过 ByInstance 键检查)
194
+ const mallsIndex = malls ? new Set(malls) : undefined;
195
+
196
+ // #9:实现覆盖跨厂商提示(实例级双字段在场、厂商 ≠ 槽级厂商)
197
+ for (const hint of realmSwitchHints ?? []) {
198
+ issues.push({ severity: 'warning', code: 'REALM_SWITCH_HINT', message: hint });
199
+ }
200
+
201
+ /** #11 供给厂商索引:suppliedProviders 键(`${vendor}\u0000${impl}`)前缀展开 */
202
+ const installedVendors = new Set(
203
+ Object.keys(suppliedProviders)
204
+ .map((k) => k.slice(0, k.indexOf('\u0000')))
205
+ .filter((v) => v.length > 0),
206
+ );
207
+
208
+ /** 绑定 credentialType 求值(#2/#6 共用):实例覆盖(恒字符串)?? 槽级(manifest 或内联)
209
+ * ?? effective 求值(v3 继承绑定——槽级 provider 缺席时经 contracts 单源取有效厂商;
210
+ * supplies 缺席 → undefined 跳过,不误报) */
211
+ const boundCredentialType = (
212
+ serviceId: string,
213
+ service: {
214
+ provider?: string | Record<string, unknown>;
215
+ implementation?: string;
216
+ instances?: Record<string, { enabled?: boolean; provider?: unknown; implementation?: unknown; credentialRef?: string }>;
217
+ },
218
+ instance?: { provider?: unknown; implementation?: unknown },
219
+ instanceId?: string,
220
+ ): string | undefined => {
221
+ if (
222
+ typeof instance?.provider === 'string' && typeof instance.implementation === 'string'
223
+ && instance.provider && instance.implementation
224
+ ) {
225
+ return suppliedProviders[providerKey(instance.provider, instance.implementation)]?.credentialType;
226
+ }
227
+ if (typeof service.provider === 'string' && typeof service.implementation === 'string') {
228
+ return suppliedProviders[providerKey(service.provider, service.implementation)]?.credentialType;
229
+ }
230
+ const ct = (service.provider as Record<string, unknown> | undefined)?.credentialType;
231
+ if (typeof ct === 'string') return ct;
232
+ // v3 继承绑定:effective 求值取 (provider, implementation)(supplies 在场才可判)
233
+ if (!supplies) return undefined;
234
+ const sk = instanceId ?? Object.keys(service.instances ?? {})[0] ?? '';
235
+ const r = contracts.resolveEffectiveService(integrations as never, serviceId, sk, supplies);
236
+ if (r.status !== 'ok' || typeof r.effective.provider !== 'string') return undefined;
237
+ return suppliedProviders[providerKey(r.effective.provider, r.effective.implementation)]?.credentialType;
238
+ };
239
+
240
+ // #1 + #2:effective credentialRef 双通道工件检查(文件在场 + .type 对齐)
241
+ if (credentialFileMissing || credentialFileType) {
242
+ for (const [serviceId, service] of Object.entries(integrations.services)) {
243
+ for (const [instanceId, instance] of Object.entries(service.instances ?? {})) {
244
+ if (instance.enabled === false) continue;
245
+ const ref = instance.credentialRef ?? service.credentialRef
246
+ ?? integrations.credentialRefByInstance?.[instanceId] ?? integrations.credentialRef;
247
+ if (typeof ref !== 'string' || !ref.startsWith('secret://')) continue;
248
+ const stem = ref.slice('secret://'.length);
249
+ // #1:凭据文件在场(credentialFileMissing 由 CLI 探测注入)
250
+ if (credentialFileMissing?.[stem] === true) {
251
+ issues.push({
252
+ severity: 'error',
253
+ code: 'CREDENTIAL_FILE_MISSING',
254
+ service: serviceId,
255
+ instance: instanceId,
256
+ message: `integrations: 凭据文件缺失 ${stem}(${serviceId}@${instanceId} effective ref=${ref};档内 config/credentials/ 无此文件)`,
257
+ });
258
+ }
259
+ // #2:凭据文件 .type 与绑定 credentialType 对齐(.type 错配 = transport 创建期
260
+ // 「未知认证类型」运行时失败的启动期前置暴露;文件无 .type / 绑定侧未知 → 跳过)
261
+ const fileType = credentialFileType?.[stem];
262
+ if (fileType) {
263
+ const boundType = boundCredentialType(serviceId, service, instance, instanceId);
264
+ if (boundType && fileType !== boundType) {
265
+ issues.push({
266
+ severity: 'error',
267
+ code: 'CREDENTIAL_TYPE_MISMATCH',
268
+ service: serviceId,
269
+ instance: instanceId,
270
+ message: `integrations: 凭据文件 ${stem}.json type=${fileType} 与绑定 credentialType ${boundType} 不一致(${serviceId}@${instanceId}——transport 创建期将按 type 选策略)`,
271
+ });
272
+ }
273
+ }
274
+ }
275
+ }
276
+ }
277
+
278
+ const noteMissingMallKey = (key: string, where: string): void => {
279
+ if (!mallsIndex) return;
280
+ const issue = instanceKeyIssue(key, where, mallsIndex);
281
+ if (issue) issues.push(issue);
282
+ };
283
+
284
+ for (const [key] of Object.entries(integrations.configByInstance ?? {})) noteMissingMallKey(key, 'configByInstance');
285
+ for (const [key] of Object.entries(integrations.credentialRefByInstance ?? {})) noteMissingMallKey(key, 'credentialRefByInstance');
286
+
287
+ // #10:通配齐步检查(v4:instances 键缺席 = 通配激活,与 '*' 同列通配形态)——
288
+ // 通配槽与显式键集槽混布(login 通配而业务槽显式键集 → 动态 mallId 登录成功但业务
289
+ // INSTANCE_NOT_FOUND;反向 → 业务成功登录失败)。合法但高度可疑,warning 级。
290
+ const wildcardServices = Object.entries(integrations.services)
291
+ .filter(([, s]) => s.instances == null || s.instances['*'] != null).map(([id]) => id);
292
+ if (wildcardServices.length > 0) {
293
+ const nonWildcard = Object.entries(integrations.services)
294
+ .filter(([, s]) => s.instances != null && s.instances['*'] == null).map(([id]) => id);
295
+ if (nonWildcard.length > 0) {
296
+ issues.push({
297
+ severity: 'warning',
298
+ code: 'WILDCARD_ALIGNMENT',
299
+ message: `integrations: 激活形态不对称(通配:${wildcardServices.join(', ')};显式键集:${nonWildcard.join(', ')})——动态 mallId 将在显式键集槽上 INSTANCE_NOT_FOUND(provider 业务限制表达位),确认是否齐步`,
300
+ });
301
+ }
302
+ }
303
+
304
+ // v4 多实例无默认:instances ≥2 且 defaultInstanceId 未解析(显式/唯一解皆无)→
305
+ // 无 scope 参数访问落全局会话(mall 模板 defaultContext 不注入)——warning 级提示
306
+ const normalized = contracts.normalizeInstances({
307
+ instances: integrations.instances as never,
308
+ defaultInstanceId: integrations.defaultInstanceId,
309
+ });
310
+ if (normalized.instances.length >= 2 && normalized.defaultInstanceId === null) {
311
+ issues.push({
312
+ severity: 'warning',
313
+ code: 'MULTI_INSTANCE_NO_DEFAULT',
314
+ message: `integrations: ${normalized.instances.length} 个部署实例(${normalized.instances.map((i) => i.id).join(', ')})均无 defaultInstanceId——无 scope 参数访问将落全局会话(默认商圈不注入)`,
315
+ });
316
+ }
317
+
318
+ for (const [serviceId, service] of Object.entries(integrations.services)) {
319
+ // 1. 槽未知(不在任何 manifest 需求声明,也非已知供给域)
320
+ const demanded = demandedServices[serviceId];
321
+ if (!demanded) {
322
+ issues.push({
323
+ severity: 'warning',
324
+ code: 'SLOT_NOT_DECLARED',
325
+ service: serviceId,
326
+ message: `integrations: 服务槽 ${serviceId} 无消费方声明(拼写错误或需求方未安装)——若为拆分前旧槽位词汇(member.main/parking.main/promotion.read/shopping-guide.main),参照 cookbook·app-config 槽位迁移配方展开为新槽`,
327
+ });
328
+ }
329
+
330
+ // 2. 槽级 provider 校验(v3:provider 缺席 = 继承/派生位——跳过,可解析性归 #12)
331
+ if (typeof service.provider === 'string') {
332
+ if (typeof service.implementation === 'string'
333
+ && !suppliedProviders[providerKey(service.provider, service.implementation)]) {
334
+ issues.push({
335
+ severity: 'error',
336
+ code: 'INSTANCE_SUPPLY_UNMATCHED',
337
+ service: serviceId,
338
+ message: `integrations: provider ${service.provider}/${service.implementation} 未命中已装供给(${serviceId} 槽级)——装对应 provider 包或改绑`,
339
+ });
340
+ }
341
+ } else if (service.provider !== undefined && !isControlledInlineProvider(service.provider)) {
342
+ issues.push({
343
+ severity: 'error',
344
+ code: 'INLINE_PROVIDER_INVALID',
345
+ service: serviceId,
346
+ message: `integrations: 内联 provider 超受控形状(${serviceId} 槽级;允许键 provider/credentialType/configSchema)`,
347
+ });
348
+ }
349
+
350
+ // 2b. 绑定 credentialType 的 schema 引用存在性(#6——manifest 绑定与内联声明同查)
351
+ if (credentialSchemaMissing) {
352
+ const ct = boundCredentialType(serviceId, service);
353
+ if (ct && credentialSchemaMissing[ct] === true) {
354
+ issues.push({
355
+ severity: 'error',
356
+ code: 'SCHEMA_FILE_MISSING',
357
+ service: serviceId,
358
+ message: `integrations: credentialType ${ct} 的 schema 引用不存在(${serviceId};manifest 声明指向缺失文件)`,
359
+ });
360
+ }
361
+ }
362
+
363
+ // 2c. 双字段同现同缺(#5 槽级内联形态校验)
364
+ if (typeof service.provider === 'object' && typeof service.implementation !== 'string') {
365
+ issues.push({
366
+ severity: 'error',
367
+ code: 'IMPL_WITHOUT_PROVIDER',
368
+ service: serviceId,
369
+ message: `integrations: 内联厂商声明必须同时提供 implementation(${serviceId} 槽级)`,
370
+ });
371
+ }
372
+
373
+ // 3. 实例级校验(v4:instances 缺席槽 = 通配激活——无显式实例键可校验)
374
+ for (const [instanceId, instance] of Object.entries(service.instances ?? {})) {
375
+ // #8:实例键 ∈ malls 白名单
376
+ noteMissingMallKey(instanceId, `services.${serviceId}.instances`);
377
+ // #5(v3):impl-only 禁止(implementation 需伴随 provider——provider 是绑定组头;
378
+ // provider-only = 派生位,合法;双缺 = 继承位,合法)
379
+ const hasProvider = instance.provider !== undefined;
380
+ const hasImpl = instance.implementation !== undefined;
381
+ if (hasImpl && !hasProvider) {
382
+ issues.push({
383
+ severity: 'error',
384
+ code: 'IMPL_WITHOUT_PROVIDER',
385
+ service: serviceId,
386
+ instance: instanceId,
387
+ message: `integrations: 实例级 implementation 需伴随 provider(${serviceId}@${instanceId}——impl-only 禁止,provider 是绑定组头)`,
388
+ });
389
+ }
390
+ // #5:实例级覆盖命中供给(双字段在场时)
391
+ if (hasProvider && hasImpl) {
392
+ const p = instance.provider;
393
+ const i = instance.implementation;
394
+ if (typeof p === 'string' && typeof i === 'string') {
395
+ if (!suppliedProviders[providerKey(p, i)]) {
396
+ issues.push({
397
+ severity: 'error',
398
+ code: 'INSTANCE_SUPPLY_UNMATCHED',
399
+ service: serviceId,
400
+ instance: instanceId,
401
+ message: `integrations: 实例级 provider 覆盖 ${p}/${i} 未命中已装供给(${serviceId}@${instanceId})`,
402
+ });
403
+ }
404
+ } else if (!isControlledInlineProvider(p)) {
405
+ issues.push({
406
+ severity: 'error',
407
+ code: 'INLINE_PROVIDER_INVALID',
408
+ service: serviceId,
409
+ instance: instanceId,
410
+ message: `integrations: 实例级内联 provider 超受控形状(${serviceId}@${instanceId})`,
411
+ });
412
+ }
413
+ }
414
+ }
415
+ }
416
+
417
+ // #11:root/domains 厂商存在性(继承层声明必须命中已装供给——vendor 集合 = 供给键前缀展开;
418
+ // 槽级内联声明不在本检查范围——F5 先声明后实施闭环归 #2/#6 形状检查)
419
+ for (const [where, vendor] of [
420
+ ['root', integrations.provider],
421
+ ...Object.entries(integrations.domains ?? {}).map(([d, cfg]): [string, string | undefined] => [`domains.${d}`, cfg?.provider]),
422
+ ] as Array<[string, string | undefined]>) {
423
+ if (typeof vendor !== 'string' || vendor.length === 0) continue;
424
+ if (!installedVendors.has(vendor)) {
425
+ issues.push({
426
+ severity: 'error',
427
+ code: 'VENDOR_NOT_INSTALLED',
428
+ module: where.startsWith('domains.') ? where.slice('domains.'.length) : undefined,
429
+ message: `integrations: ${where} provider ${vendor} 未命中已装供给——装对应 provider 包或 provider use 改绑`,
430
+ });
431
+ }
432
+ }
433
+
434
+ // #12:绑定可解析性(级联单源复用 contracts resolveEffectiveService——谓词零级联实现;
435
+ // supplies 缺席 → 整体跳过,M3 降级兼容 F5 页面无 catalog 形态。
436
+ // v4:instances 键缺席槽 = 通配激活——以 '' 探测求值(继承链可解析性仍需覆盖))
437
+ if (supplies) {
438
+ for (const [serviceId, service] of Object.entries(integrations.services)) {
439
+ const instanceIds = service.instances ? Object.keys(service.instances) : [''];
440
+ for (const instanceId of instanceIds) {
441
+ const r = contracts.resolveEffectiveService(integrations as never, serviceId, instanceId, supplies);
442
+ if (r.status === 'binding-unresolved') {
443
+ issues.push({ severity: 'error', code: 'BINDING_UNRESOLVED', service: serviceId, instance: instanceId || undefined, message: `integrations: ${r.message}` });
444
+ }
445
+ }
446
+ }
447
+ }
448
+
449
+ // #13:槽级 impl-only(zod 之外的第二道防线——doctor 读裸 JSON;实例级归 #5)
450
+ for (const [serviceId, service] of Object.entries(integrations.services)) {
451
+ if (service.implementation !== undefined && service.provider === undefined) {
452
+ issues.push({
453
+ severity: 'error',
454
+ code: 'IMPL_WITHOUT_PROVIDER',
455
+ service: serviceId,
456
+ message: `integrations: 槽级 implementation 需伴随 provider(${serviceId}——impl-only 禁止,provider 是绑定组头)`,
457
+ });
458
+ }
459
+ }
460
+
461
+ // #14:domains 键 ∈ 匹配域集(已配置槽 ∪ 需求声明的 moduleDomainOf 并集;
462
+ // typo 域 = 永不生效的静默死配置 → warning 级,staged 域(先行声明)不告警)
463
+ const matchedDomains = new Set<string>([
464
+ ...Object.keys(integrations.services).map((s) => contracts.moduleDomainOf(s)),
465
+ ...Object.keys(demandedServices).map((s) => contracts.moduleDomainOf(s)),
466
+ ]);
467
+ for (const d of Object.keys(integrations.domains ?? {})) {
468
+ if (!matchedDomains.has(d)) {
469
+ issues.push({
470
+ severity: 'warning',
471
+ code: 'DOMAIN_KEY_UNMATCHED',
472
+ module: d,
473
+ message: `integrations: domains.${d} 无对应槽位或需求声明(域前缀拼写错误?——键 = 槽位 id 点分前缀)`,
474
+ });
475
+ }
476
+ }
477
+
478
+ // #7:root/domains 死配置 —— root.config/configByInstance/domains[d].config 键 ⊆ 引用厂商
479
+ // (跨厂商取并集)各槽 schema properties 键集。并集语义:混布部署中属 A 厂商的 root 键
480
+ // 不应被 B 厂商判死。引用面(v3)= root.provider ∪ domains.*.provider ∪ 槽级(含内联
481
+ // .provider)——terse 化后槽级双字段退场,引用面不上移即静默漏检(N2)。
482
+ // 保守跳过条件(无法确证即不判死——误报会阻断部署):任一引用厂商 configSchema 文件缺失,
483
+ // 或被绑定但零 configSchema 声明(如 mock local 供给——无 schema 知识)。
484
+ if (vendorConfigKeys) {
485
+ const referenced = new Set<string>();
486
+ let schemaIncomplete = false;
487
+ const noteVendor = (vendor: string | undefined): void => {
488
+ if (typeof vendor !== 'string' || vendor.length === 0) return;
489
+ if (vendorConfigKeys[vendor]) referenced.add(vendor);
490
+ // 零 schema 知识的引用厂商(不在 keys 也不在 missing = 从未声明 configSchema)→ 整体跳过
491
+ if (!vendorConfigKeys[vendor] && !vendorConfigSchemaMissing?.[vendor]) schemaIncomplete = true;
492
+ if (vendorConfigSchemaMissing?.[vendor]) schemaIncomplete = true;
493
+ };
494
+ noteVendor(integrations.provider);
495
+ for (const cfg of Object.values(integrations.domains ?? {})) noteVendor(cfg?.provider);
496
+ for (const slot of Object.values(integrations.services)) {
497
+ const inline = slot.provider as Record<string, unknown> | undefined;
498
+ noteVendor(typeof slot.provider === 'string' ? slot.provider : inline?.provider as string | undefined);
499
+ }
500
+ if (referenced.size > 0 && !schemaIncomplete) {
501
+ const allowed = new Set<string>();
502
+ for (const v of referenced) for (const k of vendorConfigKeys[v] ?? []) allowed.add(k);
503
+ const checkLayer = (layer: Record<string, unknown> | undefined, where: string, allowedKeys?: Set<string>): void => {
504
+ for (const [k] of Object.entries(layer ?? {})) {
505
+ if (!(allowedKeys ?? allowed).has(k)) {
506
+ issues.push({
507
+ severity: 'error',
508
+ code: 'DOMAIN_CONFIG_INVALID',
509
+ message: `integrations: ${where} 键 ${k} 不在引用厂商(${[...referenced].join('/')})任何槽 schema properties 内(死配置)`,
510
+ });
511
+ }
512
+ }
513
+ };
514
+ checkLayer(integrations.config, 'root.config');
515
+ for (const [k, v] of Object.entries(integrations.configByInstance ?? {})) checkLayer(v, `root.configByInstance[${k}]`);
516
+ // 域级 config:键 ⊆ 该域生效厂商(domains[d].provider ?? root.provider)schema——
517
+ // 生效厂商缺席(全走槽级覆盖)→ 用全局并集;生效厂商 schema 缺失 → 跳过该域
518
+ for (const [d, cfg] of Object.entries(integrations.domains ?? {})) {
519
+ if (!cfg?.config) continue;
520
+ const domainVendor = typeof cfg.provider === 'string' && cfg.provider
521
+ ? cfg.provider
522
+ : (typeof integrations.provider === 'string' ? integrations.provider : undefined);
523
+ if (domainVendor === undefined) {
524
+ checkLayer(cfg.config, `domains.${d}.config`);
525
+ continue;
526
+ }
527
+ const vendorKeys = vendorConfigKeys[domainVendor];
528
+ if (!vendorKeys || vendorConfigSchemaMissing?.[domainVendor]) continue;
529
+ checkLayer(cfg.config, `domains.${d}.config`, new Set(vendorKeys));
530
+ }
531
+ }
532
+ }
533
+
534
+ // 4. 需求方缺口(warning 级——缺省/禁用是合法部署态)
535
+ for (const [serviceId, demand] of Object.entries(demandedServices)) {
536
+ const configured = integrations.services[serviceId];
537
+ if (!configured) {
538
+ issues.push({
539
+ severity: 'warning',
540
+ code: 'SLOT_NOT_CONFIGURED',
541
+ service: serviceId,
542
+ message: `integrations: 服务槽 ${serviceId}(需求方 ${demand.owners.join(', ')})未配置${demand.optional ? '(可选槽)' : '(必需槽——模块将走降级链)'}`,
543
+ });
544
+ continue;
545
+ }
546
+ const disabled = Object.entries(configured.instances ?? {})
547
+ .filter(([, inst]) => inst.enabled === false)
548
+ .map(([instanceId]) => instanceId);
549
+ if (disabled.length > 0) {
550
+ issues.push({
551
+ severity: 'warning',
552
+ code: 'INSTANCE_DISABLED',
553
+ service: serviceId,
554
+ instance: disabled.join(', '),
555
+ message: `integrations: ${serviceId} 实例禁用(${disabled.join(', ')})`,
556
+ });
557
+ }
558
+ }
559
+
560
+ // #15-#16:modules 资源节对账(v5)——type 非法恒查(裸 JSON 预启动反馈);
561
+ // 键未知 / 资源未声明需需求面(moduleResourceNeeds 在场);空声明集 = 老模块/无资源模块,跳过资源级
562
+ for (const [moduleId, cfg] of Object.entries(integrations.modules ?? {})) {
563
+ const declared = moduleResourceNeeds?.[moduleId];
564
+ if (moduleResourceNeeds && !(moduleId in moduleResourceNeeds)) {
565
+ issues.push({
566
+ severity: 'warning',
567
+ code: 'RESOURCE_NOT_DECLARED',
568
+ module: moduleId,
569
+ message: `integrations: modules.${moduleId} 未命中已装模块(键 = install id——检查 .tbox/app.json entry.id / packages/ 目录名)`,
570
+ });
571
+ }
572
+ for (const [resourceId, spec] of Object.entries(cfg?.resources ?? {})) {
573
+ const t = spec?.type;
574
+ if (typeof t !== 'string' || !(contracts.RESOURCE_TYPE_IDS as readonly string[]).includes(t)) {
575
+ issues.push({
576
+ severity: 'error',
577
+ code: 'RESOURCE_TYPE_UNKNOWN',
578
+ module: moduleId,
579
+ message: `integrations: modules.${moduleId}.resources.${resourceId} type 非法(${String(t)};词汇表 = ${contracts.RESOURCE_TYPE_IDS.join('/')})`,
580
+ });
581
+ }
582
+ if (declared && declared.size > 0 && !declared.has(resourceId)) {
583
+ issues.push({
584
+ severity: 'warning',
585
+ code: 'RESOURCE_NOT_DECLARED',
586
+ module: moduleId,
587
+ message: `integrations: modules.${moduleId}.resources.${resourceId} 未被模块 manifest contributes.resources 声明`,
588
+ });
589
+ }
590
+ }
591
+ }
592
+
593
+ return issues;
594
+ }
595
+
596
+ function providerKey(vendor: string, implementation: string): string {
597
+ return `${vendor}\u0000${implementation}`;
598
+ }
@@ -0,0 +1,53 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { resolveContractsForApp, type ResolvedContracts } from '../core/contracts-resolver.js';
4
+ import type { FileCache } from '../core/file-cache.js';
5
+
6
+ /**
7
+ * integrations.json 文件级读(C3-4 read)。
8
+ * 严格校验面(≥0.9)在场 → contracts parseIntegrationsConfig(assertNoInlineSecrets →
9
+ * 旧键直切防线 → zod strict);缺席/降级 → 原文 JSON 透传(谓词宽松透传语义——
10
+ * doctor 读裸 JSON 先例;写路径门控在工厂边缘,读不阻断静态面)。
11
+ */
12
+
13
+ export interface ReadIntegrationsResult {
14
+ /** 解析产物(严格 zod 产物或原文 JSON;文件缺席 → null) */
15
+ config: Record<string, unknown> | null;
16
+ /** 严格校验是否已应用(false = 原文透传——<0.9 或未解析降级态) */
17
+ strict: boolean;
18
+ contracts: ResolvedContracts;
19
+ }
20
+
21
+ const INTEGRATIONS_FILE = join('config', 'integrations.json');
22
+
23
+ /** 文件级读(cache 注入优先;无 cache 直读——目录枚举零缓存约束不涉文件内容) */
24
+ export function readIntegrationsConfig(
25
+ appDir: string,
26
+ cache?: FileCache,
27
+ ): Record<string, unknown> | null {
28
+ const file = join(appDir, INTEGRATIONS_FILE);
29
+ const parse = (raw: string): Record<string, unknown> => JSON.parse(raw) as Record<string, unknown>;
30
+ if (cache) return cache.read(file, parse) ?? null;
31
+ if (!existsSync(file)) return null;
32
+ try {
33
+ return parse(readFileSync(file, 'utf8'));
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ /** 带严格校验的读(求值/写路径用——门控由工厂边缘断言,本函数按 strictValidation 分派) */
40
+ export async function readIntegrationsStrict(
41
+ appDir: string,
42
+ cache?: FileCache,
43
+ ): Promise<ReadIntegrationsResult> {
44
+ const contracts = await resolveContractsForApp(appDir);
45
+ const raw = readIntegrationsConfig(appDir, cache);
46
+ if (raw === null) return { config: null, strict: false, contracts };
47
+ if (contracts.strictValidation && contracts.module.parseIntegrationsConfig) {
48
+ // 严格链:assertNoInlineSecrets → 旧键直切防线 → zod strict(contracts 单源)
49
+ const parsed = contracts.module.parseIntegrationsConfig(raw);
50
+ return { config: parsed as unknown as Record<string, unknown>, strict: true, contracts };
51
+ }
52
+ return { config: raw, strict: false, contracts };
53
+ }