@capyclaw/foundation 0.0.0-stage → 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.md CHANGED
@@ -1,3 +1,32 @@
1
- # Temporary Holding Version
1
+ # @capyclaw/foundation
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ dsh 插件基石层:接口 ABI、插件加载器与内核服务 `ctx.os`。
4
+
5
+ 应用插件不再写 `inject` / `apply` / provider 装配,只声明「我实现了哪个接口」:
6
+
7
+ ```js
8
+ import { createPlugin, skillProvider } from '@capyclaw/foundation/abi'
9
+
10
+ export const { name, inject, apply } = createPlugin({
11
+ name: 'canvas',
12
+ requires: ['canvas.runtime@^1'],
13
+ implements: [skillProvider({ skill: './skills/canvas' })],
14
+ }, import.meta.url)
15
+ ```
16
+
17
+ ## 三个入口
18
+
19
+ | 入口 | 内容 | 谁用 |
20
+ |---|---|---|
21
+ | `.` | Cordis 插件,产出 `ctx.os` | dsh profile |
22
+ | `./abi` | 描述符构建器、加载器、错误码、版本工具 | 应用插件 |
23
+ | `./kit` | 纯函数基建(迁移期复用 plugin-kit) | 应用插件 |
24
+
25
+ ## 状态
26
+
27
+ - ABI v1,六个接口适配器全部可用:`skill-provider` / `tool-provider` / `runtime-driver` /
28
+ `http-router` / `ui-panel` / `lifecycle-hook`,另有逃生舱 `raw@1`。
29
+ - 能力表按 `ctx` 实际服务探测(见 `lib/kernel.mjs` 的 `detectCapabilities`),不做空头承诺。
30
+ - 包当前为 `private`,未进入 registry;完成存量插件迁移后去掉并登记。
31
+
32
+ 设计规范见 `docs/spec/plugin-abi.md`,强制规则见 `docs/POLICY.md`。
package/abi/errors.mjs ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * ABI 错误模型:稳定的错误码 + 结构化 detail。
3
+ *
4
+ * 约定:
5
+ * - 应用插件按 code 分支,不解析 message 文案;
6
+ * - message 面向人,detail 面向程序;
7
+ * - 所有加载期失败都必须是 AbiError,不允许裸 Error 穿透。
8
+ */
9
+
10
+ /** 稳定错误码。新增只能追加,不得改语义。 */
11
+ export const ERROR_CODES = Object.freeze({
12
+ /** 插件入口形状不对:缺 name / 没有 implements / 导出了 apply 等。 */
13
+ E_PLUGIN_SHAPE: 'E_PLUGIN_SHAPE',
14
+ /** 插件声明的 ABI 主版本与内核不兼容。 */
15
+ E_ABI_MISMATCH: 'E_ABI_MISMATCH',
16
+ /** 描述符的 kind 不在内核认识的接口表里。 */
17
+ E_KIND_UNKNOWN: 'E_KIND_UNKNOWN',
18
+ /** 描述符字段不满足接口 schema。 */
19
+ E_SCHEMA: 'E_SCHEMA',
20
+ /** 内核没有提供该能力(通常是 os.* 子系统缺失)。 */
21
+ E_CAP_MISSING: 'E_CAP_MISSING',
22
+ /** 唯一性接口被第二个插件实现(例如同 id 的 runtime-driver)。 */
23
+ E_DUP_INTERFACE: 'E_DUP_INTERFACE',
24
+ /** 同名插件被重复挂载。 */
25
+ E_DUP_PLUGIN: 'E_DUP_PLUGIN',
26
+ /** 内核没有为这个 kind 注册适配器(接口定义存在但实现未就绪)。 */
27
+ E_KIND_UNAVAILABLE: 'E_KIND_UNAVAILABLE',
28
+ /** 适配器在编译期抛错。 */
29
+ E_ADAPTER_THREW: 'E_ADAPTER_THREW',
30
+ /** 插件入口没有拿到 baseUrl,相对路径无法解析。 */
31
+ E_BASE_URL_MISSING: 'E_BASE_URL_MISSING',
32
+ /** 运行时没有 ctx.os:foundation 未安装或未启用。 */
33
+ E_KERNEL_MISSING: 'E_KERNEL_MISSING',
34
+ })
35
+
36
+ export class AbiError extends Error {
37
+ /**
38
+ * @param {string} code ERROR_CODES 中的一项
39
+ * @param {string} message 面向人的说明
40
+ * @param {Record<string, unknown>} [detail] 面向程序的结构化信息
41
+ */
42
+ constructor(code, message, detail = {}) {
43
+ super(`[${code}] ${message}`)
44
+ this.name = 'AbiError'
45
+ this.code = code
46
+ this.detail = detail
47
+ if (detail.cause instanceof Error) this.cause = detail.cause
48
+ }
49
+ }
50
+
51
+ export function isAbiError(error) {
52
+ return error instanceof AbiError
53
+ }
54
+
55
+ /** 把任意异常归一成 AbiError(已归一的原样返回)。 */
56
+ export function toAbiError(error, code = ERROR_CODES.E_ADAPTER_THREW, detail = {}) {
57
+ if (error instanceof AbiError) return error
58
+ return new AbiError(code, error?.message || String(error), { ...detail, cause: error })
59
+ }
package/abi/index.mjs ADDED
@@ -0,0 +1,19 @@
1
+ /** 接口 ABI 的唯一入口:应用插件只需要 import 这个模块。 */
2
+ export { ABI_MAJOR, INTERFACE_KINDS, INTERFACE_KIND_NAMES, getInterfaceKind } from './kinds.mjs'
3
+ export {
4
+ defineInterface,
5
+ definePlugin,
6
+ createPlugin,
7
+ skillProvider,
8
+ toolProvider,
9
+ runtimeDriver,
10
+ httpRouter,
11
+ uiPanel,
12
+ lifecycleHook,
13
+ rawPlugin,
14
+ } from './spec.mjs'
15
+ export { createLoader } from './loader.mjs'
16
+ export { validateInterface } from './validate.mjs'
17
+ export { AbiError, ERROR_CODES, isAbiError, toAbiError } from './errors.mjs'
18
+ export { validateSchema } from './schema.mjs'
19
+ export { parseVersion, compareVersions, satisfies } from './version.mjs'
package/abi/kinds.mjs ADDED
@@ -0,0 +1,183 @@
1
+ /**
2
+ * 接口种类表:定义「插件可以是什么」。
3
+ *
4
+ * 一条记录 = 一种插件形状。应用插件不写装配代码,只产出这里的描述符;
5
+ * 生命周期、注册、健康检查、日志包裹由内核的适配器统一完成。
6
+ *
7
+ * 版本规则:
8
+ * - 主版本写在 kind 后缀里(`skill-provider@1`),主版本不同就是不同接口;
9
+ * - 次版本只做增量字段,内核按主版本注册适配器;
10
+ * - 破坏性变更 = 新 kind(@2)+ 旧 kind 至少并存一个大版本。
11
+ */
12
+
13
+ const nonEmpty = { type: 'string', minLength: 1 }
14
+ const kebab = { type: 'string', pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$' }
15
+
16
+ export const ABI_MAJOR = 1
17
+
18
+ export const INTERFACE_KINDS = Object.freeze({
19
+ 'skill-provider@1': Object.freeze({
20
+ kind: 'skill-provider@1',
21
+ major: 1,
22
+ title: '技能提供者',
23
+ summary: '把一个 SKILL.md 目录注册进 dsh skills 服务,可选附带契约快照与契约注记。',
24
+ unique: false,
25
+ schema: Object.freeze({
26
+ type: 'object',
27
+ required: ['skill'],
28
+ additionalProperties: false,
29
+ properties: {
30
+ name: kebab,
31
+ skill: nonEmpty,
32
+ contract: nonEmpty,
33
+ rank: { type: 'number', min: 0, max: 1000 },
34
+ note: { type: 'function' },
35
+ },
36
+ }),
37
+ }),
38
+
39
+ 'tool-provider@1': Object.freeze({
40
+ kind: 'tool-provider@1',
41
+ major: 1,
42
+ title: '工具提供者',
43
+ summary: '声明一组工具并注册到工具面(注册表 / MCP 壳 / 能力报告)。',
44
+ unique: false,
45
+ schema: Object.freeze({
46
+ type: 'object',
47
+ required: ['namespace', 'provide'],
48
+ additionalProperties: false,
49
+ properties: {
50
+ namespace: kebab,
51
+ provide: { type: 'function' },
52
+ instructions: nonEmpty,
53
+ capabilities: { type: 'object' },
54
+ },
55
+ }),
56
+ }),
57
+
58
+ 'runtime-driver@1': Object.freeze({
59
+ kind: 'runtime-driver@1',
60
+ major: 1,
61
+ title: '运行时驱动',
62
+ summary: '一个长驻进程的启动、就绪、健康与回收;端口由内核分配,应用不自己 spawn。',
63
+ unique: true,
64
+ uniqueBy: 'id',
65
+ schema: Object.freeze({
66
+ type: 'object',
67
+ required: ['id', 'entry'],
68
+ additionalProperties: false,
69
+ properties: {
70
+ id: kebab,
71
+ entry: nonEmpty,
72
+ args: { type: 'array', items: { type: 'string' } },
73
+ port: { type: ['number', 'string'] },
74
+ portArg: nonEmpty,
75
+ dir: nonEmpty,
76
+ env: { type: 'function' },
77
+ health: {
78
+ type: 'object',
79
+ required: ['path'],
80
+ additionalProperties: false,
81
+ properties: {
82
+ path: nonEmpty,
83
+ expect: { type: 'object' },
84
+ timeoutMs: { type: 'number', min: 1 },
85
+ },
86
+ },
87
+ restart: {
88
+ type: 'object',
89
+ additionalProperties: false,
90
+ properties: {
91
+ max: { type: 'integer', min: 0 },
92
+ backoffMs: { type: 'number', min: 0 },
93
+ },
94
+ },
95
+ },
96
+ }),
97
+ }),
98
+
99
+ 'http-router@1': Object.freeze({
100
+ kind: 'http-router@1',
101
+ major: 1,
102
+ title: '路由挂载者',
103
+ summary: '把一组 HTTP 路由挂到内核的共享宿主上,前缀冲突由内核检测。',
104
+ unique: false,
105
+ schema: Object.freeze({
106
+ type: 'object',
107
+ required: ['mount', 'routes'],
108
+ additionalProperties: false,
109
+ properties: {
110
+ mount: { type: 'string', pattern: '^/' },
111
+ routes: { type: 'function' },
112
+ host: { type: 'string', enum: ['shared', 'own'] },
113
+ },
114
+ }),
115
+ }),
116
+
117
+ 'ui-panel@1': Object.freeze({
118
+ kind: 'ui-panel@1',
119
+ major: 1,
120
+ title: '界面面板',
121
+ summary: '把静态资源目录挂到挂载点,缓存头与 404 兜底由内核处理。',
122
+ unique: false,
123
+ schema: Object.freeze({
124
+ type: 'object',
125
+ required: ['mount', 'root'],
126
+ additionalProperties: false,
127
+ properties: {
128
+ mount: { type: 'string', pattern: '^/' },
129
+ root: nonEmpty,
130
+ index: nonEmpty,
131
+ cache: { type: 'string', enum: ['no-store', 'revalidate', 'immutable'] },
132
+ },
133
+ }),
134
+ }),
135
+
136
+ 'lifecycle-hook@1': Object.freeze({
137
+ kind: 'lifecycle-hook@1',
138
+ major: 1,
139
+ title: '生命周期钩子',
140
+ summary: '在安装 / 启动 / 停止 / 升级四个时点插入行为,时序与幂等由内核保证。',
141
+ unique: false,
142
+ schema: Object.freeze({
143
+ type: 'object',
144
+ additionalProperties: false,
145
+ properties: {
146
+ onInstall: { type: 'function' },
147
+ onStart: { type: 'function' },
148
+ onStop: { type: 'function' },
149
+ onUpgrade: { type: 'function' },
150
+ },
151
+ }),
152
+ /** schema 表达不了「至少一个钩子」,用额外校验补。 */
153
+ extra(impl, errors) {
154
+ const hooks = ['onInstall', 'onStart', 'onStop', 'onUpgrade'].filter((key) => typeof impl[key] === 'function')
155
+ if (!hooks.length) errors.push('$ 至少需要实现一个钩子(onInstall/onStart/onStop/onUpgrade)')
156
+ },
157
+ }),
158
+
159
+ 'raw@1': Object.freeze({
160
+ kind: 'raw@1',
161
+ major: 1,
162
+ title: '原生插件(逃生舱)',
163
+ summary: '极少数需要自定义装配的插件;必须在 foundation.allowlist.json 登记,且不得与其他接口混用。',
164
+ unique: false,
165
+ exclusive: true,
166
+ schema: Object.freeze({
167
+ type: 'object',
168
+ required: ['apply'],
169
+ additionalProperties: false,
170
+ properties: {
171
+ apply: { type: 'function' },
172
+ },
173
+ }),
174
+ }),
175
+ })
176
+
177
+ /** 内核当前认识的 kind 列表(供错误信息与文档生成)。 */
178
+ export const INTERFACE_KIND_NAMES = Object.freeze(Object.keys(INTERFACE_KINDS))
179
+
180
+ /** 取接口定义;未知 kind 返回 undefined,由调用方转成 E_KIND_UNKNOWN。 */
181
+ export function getInterfaceKind(kind) {
182
+ return INTERFACE_KINDS[kind]
183
+ }
package/abi/loader.mjs ADDED
@@ -0,0 +1,249 @@
1
+ /**
2
+ * 插件加载器:把描述符编译成运行时实例。
3
+ *
4
+ * 四段式,任何副作用都在最后一段:
5
+ * 1. validate 纯检查(形状 / 版本 / 能力 / 唯一性),不碰任何资源;
6
+ * 2. compile 逐个接口交给适配器;失败则回滚已编译的实例;
7
+ * 3. start 运行时就绪(子进程拉起、监听等),异步,失败进 failures;
8
+ * 4. commit 登记插件、能力与唯一性占用。
9
+ *
10
+ * 这样保证:坏描述符不会产生半个插件,也不会留下悬挂端口或子进程。
11
+ */
12
+ import { ABI_MAJOR, getInterfaceKind } from './kinds.mjs'
13
+ import { validateInterface } from './validate.mjs'
14
+ import { AbiError, ERROR_CODES, toAbiError } from './errors.mjs'
15
+ import { satisfies } from './version.mjs'
16
+
17
+ /**
18
+ * @param {{
19
+ * adapters: Record<string, { kind: string, compile: Function }>,
20
+ * capabilities?: Record<string, string>,
21
+ * log?: object,
22
+ * abiMajor?: number
23
+ * }} options
24
+ */
25
+ export function createLoader({ adapters = {}, capabilities = {}, log = null, abiMajor = ABI_MAJOR } = {}) {
26
+ /** @type {Map<string, { spec: object, entries: object[], handle: object }>} */
27
+ const plugins = new Map()
28
+ /** 唯一性占用:key -> 插件名 */
29
+ const uniques = new Map()
30
+ /** 已提供能力:name -> { plugin, version } */
31
+ const provided = new Map()
32
+ /** 运行期失败(start / 异步就绪) */
33
+ const failures = []
34
+
35
+ function adapterOf(kind) {
36
+ const adapter = adapters[kind]
37
+ if (!adapter || typeof adapter.compile !== 'function') {
38
+ throw new AbiError(ERROR_CODES.E_KIND_UNAVAILABLE,
39
+ `接口 ${kind} 已定义但内核没有注册适配器`, { kind, available: Object.keys(adapters) })
40
+ }
41
+ return adapter
42
+ }
43
+
44
+ function assertCapabilities(spec) {
45
+ const missing = []
46
+ for (const requirement of spec.requires) {
47
+ if (!requirement.name.startsWith('os.')) continue // 跨插件能力由 suite 组合期校验,运行时只报告
48
+ const version = capabilities[requirement.name]
49
+ if (!version) {
50
+ missing.push({ name: requirement.name, range: requirement.range, reason: '未提供' })
51
+ } else if (!satisfies(version, requirement.range)) {
52
+ missing.push({ name: requirement.name, range: requirement.range, reason: `提供 ${version}` })
53
+ }
54
+ }
55
+ if (missing.length) {
56
+ throw new AbiError(ERROR_CODES.E_CAP_MISSING,
57
+ `插件 ${spec.name} 需要的能力不可用:${missing.map((m) => `${m.name}@${m.range}(${m.reason})`).join(',')}`,
58
+ { plugin: spec.name, missing })
59
+ }
60
+ }
61
+
62
+ function claimUniqueness(impls, pluginName, committed) {
63
+ const batch = new Map()
64
+ for (const impl of impls) {
65
+ const definition = getInterfaceKind(impl.kind)
66
+ if (!definition?.unique) continue
67
+ const key = definition.uniqueBy ? `${impl.kind}:${impl[definition.uniqueBy]}` : impl.kind
68
+ const owner = committed.get(key) || batch.get(key)
69
+ if (owner) {
70
+ throw new AbiError(ERROR_CODES.E_DUP_INTERFACE,
71
+ `接口 ${key} 已被 ${owner} 占用,${pluginName} 不能重复实现`, { key, owner, plugin: pluginName })
72
+ }
73
+ batch.set(key, pluginName)
74
+ }
75
+ return batch
76
+ }
77
+
78
+ /**
79
+ * 挂载一个插件。同步完成校验与注册,返回 handle。
80
+ * @param {object} spec definePlugin 的结果
81
+ * @param {object} [config]
82
+ * @param {{ baseUrl?: string }} [context]
83
+ */
84
+ function mount(spec, config = {}, context = {}) {
85
+ if (!spec || spec.kind !== 'plugin') {
86
+ throw new AbiError(ERROR_CODES.E_PLUGIN_SHAPE, 'mount 需要 definePlugin 产出的插件描述符', {
87
+ got: spec?.kind ?? typeof spec,
88
+ })
89
+ }
90
+ if (spec.apiVersion !== abiMajor) {
91
+ throw new AbiError(ERROR_CODES.E_ABI_MISMATCH,
92
+ `插件 ${spec.name} 声明 ABI v${spec.apiVersion},内核为 v${abiMajor}`,
93
+ { plugin: spec.name, apiVersion: spec.apiVersion, abiMajor })
94
+ }
95
+ if (plugins.has(spec.name)) {
96
+ throw new AbiError(ERROR_CODES.E_DUP_PLUGIN, `插件 ${spec.name} 已挂载`, { plugin: spec.name })
97
+ }
98
+
99
+ /* 1. validate —— 纯检查,无副作用 */
100
+ assertCapabilities(spec)
101
+ for (const impl of spec.implements) {
102
+ const { definition, errors } = validateInterface(impl)
103
+ if (!definition) {
104
+ throw new AbiError(ERROR_CODES.E_KIND_UNKNOWN, `未知接口种类 ${impl.kind}`, { plugin: spec.name, kind: impl.kind })
105
+ }
106
+ if (errors.length) {
107
+ throw new AbiError(ERROR_CODES.E_SCHEMA, `${impl.kind} 描述符非法:${errors.join(';')}`,
108
+ { plugin: spec.name, kind: impl.kind, errors })
109
+ }
110
+ adapterOf(impl.kind)
111
+ }
112
+ const batchUniques = claimUniqueness(spec.implements, spec.name, uniques)
113
+
114
+ /* 2. compile —— 失败回滚 */
115
+ const entries = []
116
+ try {
117
+ for (const impl of spec.implements) {
118
+ const adapter = adapterOf(impl.kind)
119
+ let instance
120
+ try {
121
+ instance = adapter.compile(impl, { spec, config, context, log, loader: api })
122
+ } catch (error) {
123
+ throw toAbiError(error, ERROR_CODES.E_ADAPTER_THREW, { plugin: spec.name, kind: impl.kind })
124
+ }
125
+ entries.push({ impl, instance: instance || {} })
126
+ }
127
+ } catch (error) {
128
+ disposeEntries(entries)
129
+ throw error
130
+ }
131
+
132
+ /* 3. start —— 异步就绪,失败记录不抛出 */
133
+ const state = { disposed: false }
134
+ const ready = startEntries(spec, entries, state)
135
+
136
+ /* 4. commit */
137
+ const ownedProvides = []
138
+ for (const capability of spec.provides) {
139
+ provided.set(capability.name, { plugin: spec.name, version: capability.range })
140
+ ownedProvides.push(capability.name)
141
+ }
142
+ for (const [key, owner] of batchUniques) uniques.set(key, owner)
143
+
144
+ let disposed = false
145
+ const handle = {
146
+ name: spec.name,
147
+ spec,
148
+ interfaces: entries.map((entry) => entry.impl.kind),
149
+ ready,
150
+ dispose() {
151
+ if (disposed) return
152
+ disposed = true
153
+ state.disposed = true
154
+ disposeEntries(entries)
155
+ for (const key of batchUniques.keys()) uniques.delete(key)
156
+ for (const name of ownedProvides) provided.delete(name)
157
+ plugins.delete(spec.name)
158
+ },
159
+ }
160
+ plugins.set(spec.name, { spec, entries, handle })
161
+ return handle
162
+ }
163
+
164
+ function startEntries(spec, entries, state) {
165
+ const pending = []
166
+ for (const entry of entries) {
167
+ if (typeof entry.instance.start !== 'function') continue
168
+ pending.push(Promise.resolve()
169
+ .then(() => {
170
+ if (state.disposed) return undefined
171
+ return entry.instance.start()
172
+ })
173
+ .catch((error) => {
174
+ const normalized = toAbiError(error, ERROR_CODES.E_ADAPTER_THREW,
175
+ { plugin: spec.name, kind: entry.impl.kind, phase: 'start' })
176
+ failures.push(normalized)
177
+ log?.error?.(`[foundation] ${normalized.message}`)
178
+ }))
179
+ }
180
+ return Promise.all(pending).then(() => undefined)
181
+ }
182
+
183
+ function disposeEntries(entries) {
184
+ for (const entry of [...entries].reverse()) {
185
+ try {
186
+ entry.instance?.dispose?.()
187
+ } catch (error) {
188
+ const normalized = toAbiError(error, ERROR_CODES.E_ADAPTER_THREW,
189
+ { plugin: entry.impl.kind, phase: 'dispose' })
190
+ failures.push(normalized)
191
+ log?.error?.(`[foundation] ${normalized.message}`)
192
+ }
193
+ }
194
+ entries.length = 0
195
+ }
196
+
197
+ /** 当前可用的能力版本:内核承诺优先,其次插件提供。 */
198
+ function capability(name) {
199
+ return capabilities[name] || provided.get(name)?.version || null
200
+ }
201
+
202
+ /** 未解析的跨插件能力(运行时只报告,组合期才是硬闸)。 */
203
+ function unresolved() {
204
+ const out = []
205
+ for (const { spec } of plugins.values()) {
206
+ for (const requirement of spec.requires) {
207
+ if (requirement.name.startsWith('os.')) continue
208
+ if (provided.has(requirement.name) || capabilities[requirement.name]) continue
209
+ out.push({ plugin: spec.name, capability: requirement.name, range: requirement.range })
210
+ }
211
+ }
212
+ return out
213
+ }
214
+
215
+ /** 内核状态快照:给 os.report() 与健康检查用。 */
216
+ function report() {
217
+ const capabilityView = { ...capabilities }
218
+ for (const [name, info] of provided) capabilityView[name] = info.version
219
+ return {
220
+ abiVersion: abiMajor,
221
+ plugins: [...plugins.values()].map(({ spec, entries }) => ({
222
+ name: spec.name,
223
+ interfaces: entries.map((entry) => entry.impl.kind),
224
+ requires: spec.requires.map((r) => r.raw),
225
+ provides: spec.provides.map((p) => p.raw),
226
+ })),
227
+ capabilities: capabilityView,
228
+ unresolved: unresolved(),
229
+ failures: failures.map((error) => ({ code: error.code, message: error.message, detail: error.detail })),
230
+ }
231
+ }
232
+
233
+ function disposeAll() {
234
+ for (const { handle } of [...plugins.values()].reverse()) handle.dispose()
235
+ plugins.clear()
236
+ uniques.clear()
237
+ provided.clear()
238
+ }
239
+
240
+ const api = Object.freeze({
241
+ mount,
242
+ report,
243
+ capability,
244
+ disposeAll,
245
+ get size() { return plugins.size },
246
+ })
247
+
248
+ return api
249
+ }
package/abi/schema.mjs ADDED
@@ -0,0 +1,98 @@
1
+ /**
2
+ * 极简 schema 校验器:只覆盖 ABI 描述符需要的子集,不引入依赖。
3
+ *
4
+ * 支持的约束:type(含类型数组)/ required / properties / additionalProperties /
5
+ * items / minItems / enum / pattern / min / max / minLength / maxLength。
6
+ *
7
+ * 刻意不做的事:$ref、oneOf/anyOf、format、默认值填充。描述符是代码不是配置文件,
8
+ * 需要更强表达力时写 `extra()` 校验,而不是把 schema 语言做大。
9
+ */
10
+
11
+ const TYPE_CHECKS = {
12
+ string: (v) => typeof v === 'string',
13
+ number: (v) => typeof v === 'number' && Number.isFinite(v),
14
+ integer: (v) => Number.isInteger(v),
15
+ boolean: (v) => typeof v === 'boolean',
16
+ object: (v) => v !== null && typeof v === 'object' && !Array.isArray(v),
17
+ array: (v) => Array.isArray(v),
18
+ function: (v) => typeof v === 'function',
19
+ }
20
+
21
+ function describe(value) {
22
+ if (value === null) return 'null'
23
+ if (Array.isArray(value)) return 'array'
24
+ return typeof value
25
+ }
26
+
27
+ /**
28
+ * @param {unknown} value
29
+ * @param {object} schema
30
+ * @param {string} [path] 出错时的字段路径根
31
+ * @returns {string[]} 错误列表;空数组表示通过
32
+ */
33
+ export function validateSchema(value, schema, path = '$') {
34
+ const errors = []
35
+ visit(value, schema, path, errors)
36
+ return errors
37
+ }
38
+
39
+ function visit(value, schema, path, errors) {
40
+ if (!schema) return
41
+
42
+ if (schema.type !== undefined) {
43
+ const types = Array.isArray(schema.type) ? schema.type : [schema.type]
44
+ const known = types.filter((type) => TYPE_CHECKS[type])
45
+ if (known.length !== types.length) {
46
+ errors.push(`${path} 的 schema 声明了未知类型 ${types.join('|')}`)
47
+ return
48
+ }
49
+ if (!types.some((type) => TYPE_CHECKS[type](value))) {
50
+ errors.push(`${path} 应为 ${types.join('|')},实际 ${describe(value)}`)
51
+ return
52
+ }
53
+ }
54
+
55
+ if (typeof value === 'string') {
56
+ if (schema.minLength !== undefined && value.length < schema.minLength) {
57
+ errors.push(`${path} 长度不得小于 ${schema.minLength}`)
58
+ }
59
+ if (schema.maxLength !== undefined && value.length > schema.maxLength) {
60
+ errors.push(`${path} 长度不得大于 ${schema.maxLength}`)
61
+ }
62
+ if (schema.pattern && !new RegExp(schema.pattern).test(value)) {
63
+ errors.push(`${path} 不匹配 ${schema.pattern}`)
64
+ }
65
+ if (schema.enum && !schema.enum.includes(value)) {
66
+ errors.push(`${path} 必须是 ${schema.enum.join(' | ')} 之一`)
67
+ }
68
+ }
69
+
70
+ if (typeof value === 'number') {
71
+ if (schema.min !== undefined && value < schema.min) errors.push(`${path} 不得小于 ${schema.min}`)
72
+ if (schema.max !== undefined && value > schema.max) errors.push(`${path} 不得大于 ${schema.max}`)
73
+ }
74
+
75
+ if (Array.isArray(value)) {
76
+ if (schema.minItems !== undefined && value.length < schema.minItems) {
77
+ errors.push(`${path} 至少需要 ${schema.minItems} 项`)
78
+ }
79
+ if (schema.items) {
80
+ value.forEach((item, index) => visit(item, schema.items, `${path}[${index}]`, errors))
81
+ }
82
+ }
83
+
84
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
85
+ const props = schema.properties || {}
86
+ for (const key of schema.required || []) {
87
+ if (value[key] === undefined) errors.push(`${path}.${key} 必填`)
88
+ }
89
+ if (schema.additionalProperties === false) {
90
+ for (const key of Object.keys(value)) {
91
+ if (!(key in props)) errors.push(`${path}.${key} 未知字段`)
92
+ }
93
+ }
94
+ for (const [key, sub] of Object.entries(props)) {
95
+ if (value[key] !== undefined) visit(value[key], sub, `${path}.${key}`, errors)
96
+ }
97
+ }
98
+ }