@deepseek-ai/dsh-app-boot 0.2.2-alpha.3 → 0.2.2-rc.2

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 (73) hide show
  1. package/README.i18n.yaml +4 -4
  2. package/README.md +4 -2
  3. package/README.zh.md +4 -2
  4. package/lib/index.js +37 -17
  5. package/lib/types/bundle-platform.d.ts +4 -2
  6. package/lib/types/bundle-platform.d.ts.map +1 -0
  7. package/lib/types/bundle-platform.js.map +1 -0
  8. package/lib/types/compatibility-preflight.d.ts.map +1 -0
  9. package/lib/types/compatibility-preflight.js.map +1 -0
  10. package/lib/types/config-schema/collect.d.ts.map +1 -0
  11. package/lib/types/config-schema/collect.js.map +1 -0
  12. package/lib/types/config-schema/document.d.ts.map +1 -0
  13. package/lib/types/config-schema/document.js.map +1 -0
  14. package/lib/types/config-schema/index.d.ts.map +1 -0
  15. package/lib/types/config-schema/index.js.map +1 -0
  16. package/lib/types/config-schema/native.d.ts.map +1 -0
  17. package/lib/types/config-schema/native.js.map +1 -0
  18. package/lib/types/config-schema/pattern.d.ts.map +1 -0
  19. package/lib/types/config-schema/pattern.js.map +1 -0
  20. package/lib/types/config-schema/projector.d.ts.map +1 -0
  21. package/lib/types/config-schema/projector.js.map +1 -0
  22. package/lib/types/config-schema/types.d.ts.map +1 -0
  23. package/lib/types/config-schema/types.js.map +1 -0
  24. package/lib/types/index.d.ts +1 -1
  25. package/lib/types/index.d.ts.map +1 -0
  26. package/lib/types/index.js.map +1 -0
  27. package/lib/types/package-meta.d.ts.map +1 -0
  28. package/lib/types/package-meta.js.map +1 -0
  29. package/lib/types/plugin-compatibility.d.ts.map +1 -0
  30. package/lib/types/plugin-compatibility.js.map +1 -0
  31. package/lib/types/profile-compatibility.d.ts.map +1 -0
  32. package/lib/types/profile-compatibility.js.map +1 -0
  33. package/lib/types/profile-context.d.ts.map +1 -0
  34. package/lib/types/profile-context.js.map +1 -0
  35. package/lib/types/profile-plugins.d.ts.map +1 -0
  36. package/lib/types/profile-plugins.js.map +1 -0
  37. package/lib/types/profile-resolution/legacy-links.d.ts.map +1 -0
  38. package/lib/types/profile-resolution/legacy-links.js.map +1 -0
  39. package/lib/types/profile-resolution/resolver.d.ts.map +1 -0
  40. package/lib/types/profile-resolution/resolver.js.map +1 -0
  41. package/lib/types/profile-resolution/service.d.ts.map +1 -0
  42. package/lib/types/profile-resolution/service.js.map +1 -0
  43. package/lib/types/profile-resolution/worker-bootstrap.d.ts.map +1 -0
  44. package/lib/types/profile-resolution/worker-bootstrap.js.map +1 -0
  45. package/lib/types/profile-sanitize.d.ts.map +1 -0
  46. package/lib/types/profile-sanitize.js.map +1 -0
  47. package/lib/types/profile.d.ts.map +1 -0
  48. package/lib/types/profile.js.map +1 -0
  49. package/lib/types/startup-diagnostics.d.ts.map +1 -0
  50. package/lib/types/startup-diagnostics.js.map +1 -0
  51. package/package.json +25 -21
  52. package/src/bundle-platform.ts +35 -0
  53. package/src/compatibility-preflight.ts +187 -0
  54. package/src/config-schema/collect.ts +249 -0
  55. package/src/config-schema/document.ts +169 -0
  56. package/src/config-schema/index.ts +36 -0
  57. package/src/config-schema/native.ts +16 -0
  58. package/src/config-schema/pattern.ts +62 -0
  59. package/src/config-schema/projector.ts +455 -0
  60. package/src/config-schema/types.ts +95 -0
  61. package/src/index.ts +1286 -0
  62. package/src/package-meta.ts +200 -0
  63. package/src/plugin-compatibility.ts +117 -0
  64. package/src/profile-compatibility.ts +141 -0
  65. package/src/profile-context.ts +85 -0
  66. package/src/profile-plugins.ts +133 -0
  67. package/src/profile-resolution/legacy-links.ts +33 -0
  68. package/src/profile-resolution/resolver.ts +1072 -0
  69. package/src/profile-resolution/service.ts +148 -0
  70. package/src/profile-resolution/worker-bootstrap.ts +12 -0
  71. package/src/profile-sanitize.ts +34 -0
  72. package/src/profile.ts +762 -0
  73. package/src/startup-diagnostics.ts +104 -0
@@ -0,0 +1,249 @@
1
+ /** Boot-free inspection of declared plugin Config schemas using profile module resolution. */
2
+
3
+ import { readFile, realpath } from 'node:fs/promises'
4
+ import { extname, join } from 'node:path'
5
+ import { fileURLToPath, pathToFileURL } from 'node:url'
6
+ import * as yaml from 'js-yaml'
7
+ import Loader, { EntryGroup, ModuleLoader, isJsExpr, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
8
+ import Group from '@deepseek-ai/cordis-plugin-group'
9
+ import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
10
+ import type { Profile, RuntimeResolution } from '../profile.ts'
11
+ import { installRuntimeInterception } from '../profile-resolution/resolver.ts'
12
+ import { buildConfigSchemaDocument } from './document.ts'
13
+ import type { CollectedConfigEntry, ConfigSchemaDiagnostic, ConfigSchemaDump } from './types.ts'
14
+ import { isNativeConfigSchema } from './native.ts'
15
+
16
+ interface ParsedEntry {
17
+ id?: string
18
+ name: string
19
+ config?: unknown
20
+ group?: boolean | null
21
+ disabled?: unknown
22
+ }
23
+
24
+ function objectLike(value: unknown): value is object {
25
+ return value !== null && (typeof value === 'object' || typeof value === 'function')
26
+ }
27
+
28
+ function record(value: unknown): value is Record<string, unknown> {
29
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
30
+ }
31
+
32
+ function configOf(plugin: unknown): unknown {
33
+ return objectLike(plugin) ? Reflect.get(plugin, 'Config') : undefined
34
+ }
35
+
36
+ function validateMetadata(row: object): void {
37
+ for (const key of ['id', 'name']) {
38
+ const value: unknown = Reflect.get(row, key)
39
+ if (value !== undefined && typeof value !== 'string') throw new Error(`${key} must be a literal string`)
40
+ }
41
+ const group: unknown = Reflect.get(row, 'group')
42
+ if (group !== undefined && group !== null && typeof group !== 'boolean') throw new Error('group must be a literal boolean or null')
43
+ }
44
+
45
+ function validateEntry(value: unknown): asserts value is ParsedEntry {
46
+ if (!record(value) || typeof value.name !== 'string') throw new Error('each entry must be a mapping with a literal plugin name')
47
+ validateMetadata(value)
48
+ }
49
+
50
+ function entryList(value: unknown): unknown[] {
51
+ if (value === undefined) throw new Error('entry list config is missing')
52
+ if (!Array.isArray(value)) throw new Error('expected a literal entry list; config expressions are not evaluated')
53
+ return value as unknown[]
54
+ }
55
+
56
+ function includePatches(value: unknown): PatchOptions[] | undefined {
57
+ if (value === undefined) return undefined
58
+ if (!Array.isArray(value)) throw new Error('include patches must be a literal patch list; config expressions are not evaluated')
59
+ for (const patch of value as unknown[]) {
60
+ if (!record(patch) || isJsExpr(patch)) throw new Error('include patches must be literal mappings; config expressions are not evaluated')
61
+ validateMetadata(patch)
62
+ if (patch.insert !== undefined) entryList(patch.insert)
63
+ }
64
+ return value as PatchOptions[]
65
+ }
66
+
67
+ /**
68
+ * Generate JSON Schema from parsed entry rows and root-tree patches without applying plugins or evaluating expressions.
69
+ * Imports, Config getters, and lazy schema builders execute trusted plugin code; transform callbacks do not. Calls must not overlap
70
+ * another profile-resolution interception; module imports remain cached after the interception is released.
71
+ * @param profile - prepared profile whose directory anchors root module and include resolution.
72
+ * @param entries - unvalidated rows from profile composition; malformed rows become positioned diagnostics without losing siblings.
73
+ * @param resolution - the same immutable package resolution used for profile boot.
74
+ * @param diagnostics - existing composition diagnostics; copied into the returned catalog.
75
+ * @returns a JSON Schema document with partial-result diagnostics and Config references under `x-cordis`.
76
+ * @throws when Node's profile module resolution cannot be installed.
77
+ */
78
+ export async function collectConfigSchemas(
79
+ profile: Profile,
80
+ entries: readonly unknown[],
81
+ resolution: RuntimeResolution,
82
+ diagnostics: readonly ConfigSchemaDiagnostic[] = [],
83
+ ): Promise<ConfigSchemaDump> {
84
+ const result: { entries: CollectedConfigEntry[]; diagnostics: ConfigSchemaDiagnostic[] } = {
85
+ entries: [], diagnostics: [...diagnostics],
86
+ }
87
+ const byOptions = new Map<object, CollectedConfigEntry>()
88
+ const interception = installRuntimeInterception(resolution)
89
+ try {
90
+ const loader = ModuleLoader.fromInternal()
91
+ if (loader === undefined) throw new Error('config schema dump requires the Node module loader used by profile resolution')
92
+ const builtins: Record<string, unknown> = { group: Group, include: Include }
93
+ const nativeCarriers = new Map<string, Promise<{ group: unknown; include: unknown }>>()
94
+ const carrier = async (plugin: unknown, baseUrl: string): Promise<'group' | 'include' | undefined> => {
95
+ if (plugin === Group) return 'group'
96
+ if (plugin === Include) return 'include'
97
+ if (!objectLike(plugin) || !Reflect.get(plugin, EntryGroup.key)) return undefined
98
+ let resolved = nativeCarriers.get(baseUrl)
99
+ if (resolved === undefined) {
100
+ // Include may be bundled into app-boot; compare against the native packages resolved for this tree as well.
101
+ resolved = Promise.allSettled([
102
+ loader.import('@deepseek-ai/cordis-plugin-group', baseUrl, {}),
103
+ loader.import('@deepseek-ai/cordis-plugin-include', baseUrl, {}),
104
+ ]).then(([group, include]) => {
105
+ const groupPlugin: unknown = group.status === 'fulfilled' ? Loader.prototype.unwrapExports(group.value) : undefined
106
+ const includePlugin: unknown = include.status === 'fulfilled' ? Loader.prototype.unwrapExports(include.value) : undefined
107
+ return { group: groupPlugin, include: includePlugin }
108
+ })
109
+ nativeCarriers.set(baseUrl, resolved)
110
+ }
111
+ const native = await resolved
112
+ if (plugin === native.group) return 'group'
113
+ if (plugin === native.include) return 'include'
114
+ throw new Error('unrecognized Loader tree carrier; use cordis:group or cordis:include for native child collection')
115
+ }
116
+ const ancestors = new Set<string>()
117
+ const report = (path: string, error: unknown): void => {
118
+ result.diagnostics.push({ level: 'error', path, message: error instanceof Error ? error.message : String(error) })
119
+ }
120
+ const warn = (path: string) => (message: string, ...args: unknown[]): void => {
121
+ let index = 0
122
+ result.diagnostics.push({ level: 'warning', path, message: message.replace(/%C/g, () => JSON.stringify(args[index++])) })
123
+ }
124
+
125
+ const walkInclude = async (config: unknown, baseUrl: string, path: string): Promise<void> => {
126
+ if (config === undefined) throw new Error('include config is missing')
127
+ if (!record(config) || isJsExpr(config)) throw new Error('include config must be literal; config expressions are not evaluated')
128
+ if (typeof config.path !== 'string') throw new Error('include path must be literal; config expressions are not evaluated')
129
+ const filename = fileURLToPath(new URL(config.path, baseUrl))
130
+ const extension = extname(filename)
131
+ if (!['.json', '.yaml', '.yml'].includes(extension)) throw new Error(`include extension ${JSON.stringify(extension)} is not supported`)
132
+ let source: unknown
133
+ let canonical: string
134
+ try {
135
+ canonical = await realpath(filename)
136
+ } catch (error) {
137
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
138
+ canonical = filename
139
+ }
140
+ if (ancestors.has(canonical)) throw new Error(`include cycle at ${filename}`)
141
+ let content: string | undefined
142
+ try {
143
+ content = await readFile(filename, 'utf8')
144
+ } catch (error) {
145
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
146
+ source = config.initial
147
+ if (source === undefined) throw new Error(`include file not found: ${filename}`)
148
+ }
149
+ if (content !== undefined) {
150
+ try {
151
+ source = extension === '.json' ? JSON.parse(content) : yaml.load(content, { schema: entryListSchema })
152
+ } catch (error) {
153
+ // Parser messages can quote configuration values; retain only the file and location.
154
+ const at = error instanceof yaml.YAMLException ? ` at line ${error.mark.line + 1}, column ${error.mark.column + 1}` : ''
155
+ throw new Error(`invalid ${extension === '.json' ? 'JSON' : 'YAML'} include ${filename}${at}`)
156
+ }
157
+ }
158
+ const patches = includePatches(config.patches)
159
+ const rows = entryList(source)
160
+ let children: EntryOptions[]
161
+ try {
162
+ children = applyEntryPatches(rows as EntryOptions[], patches, warn(path))
163
+ } catch (error) {
164
+ throw new Error(`include patches could not be composed for ${filename}; child declarations are unavailable`, { cause: error })
165
+ }
166
+ ancestors.add(canonical)
167
+ try {
168
+ await walk(children, new URL('.', pathToFileURL(filename)).href, `${path}/include`)
169
+ } finally {
170
+ ancestors.delete(canonical)
171
+ }
172
+ }
173
+
174
+ const walk = async (rows: readonly unknown[], baseUrl: string, prefix: string): Promise<void> => {
175
+ for (const [index, value] of rows.entries()) {
176
+ const path = `${prefix}/${index}`
177
+ const identity = record(value) ? value : undefined
178
+ const entry: CollectedConfigEntry = {
179
+ path,
180
+ ...(typeof identity?.id === 'string' ? { id: identity.id } : {}),
181
+ ...(typeof identity?.name === 'string' ? { name: identity.name } : {}),
182
+ status: 'error',
183
+ }
184
+ result.entries.push(entry)
185
+ const row = value
186
+ try {
187
+ validateEntry(row)
188
+ } catch (error) {
189
+ report(path, error)
190
+ continue
191
+ }
192
+ entry.status = 'absent'
193
+ byOptions.set(row, entry)
194
+ let plugin: unknown
195
+ try {
196
+ let exports: unknown
197
+ if (row.name.startsWith('cordis:')) {
198
+ const name = row.name.slice(7)
199
+ if (!Object.hasOwn(builtins, name)) throw new Error(`unknown Cordis builtin ${JSON.stringify(row.name)}`)
200
+ exports = builtins[name]
201
+ } else exports = await loader.import(row.name, baseUrl, {})
202
+ plugin = Loader.prototype.unwrapExports(exports)
203
+ const schema = configOf(plugin)
204
+ if (schema !== undefined && schema !== null) {
205
+ if (!isNativeConfigSchema(schema)) {
206
+ entry.status = 'unsupported'
207
+ report(path, 'Config is not a native Schemastery schema')
208
+ } else {
209
+ entry.status = 'schema'
210
+ entry.native = schema
211
+ }
212
+ }
213
+ } catch (error) {
214
+ entry.status = 'error'
215
+ report(path, error)
216
+ }
217
+ try {
218
+ const kind = await carrier(plugin, baseUrl)
219
+ if (kind === undefined) continue
220
+ entry.tree = kind
221
+ // The Loader never initializes a disabled carrier unless group: true forces it, so a missing config declares no children.
222
+ if (row.config === undefined && row.group !== true && (row.disabled === true || isJsExpr(row.disabled))) continue
223
+ if (kind === 'group') await walk(entryList(row.config), baseUrl, `${path}/config`)
224
+ else await walkInclude(row.config, baseUrl, path)
225
+ } catch (error) {
226
+ report(path, error)
227
+ }
228
+ }
229
+ }
230
+
231
+ await walk(entries, pathToFileURL(join(profile.dir, 'cordis.yml')).href, '')
232
+ const targets = new Map<string, CollectedConfigEntry>()
233
+ const indexTargets = (rows: readonly unknown[]): void => {
234
+ for (const row of rows) {
235
+ if (!record(row)) continue
236
+ if (typeof row.id === 'string' && row.id) {
237
+ const entry = byOptions.get(row)
238
+ if (entry) targets.set(row.id, entry)
239
+ else targets.delete(row.id)
240
+ }
241
+ if (row.group && Array.isArray(row.config)) indexTargets(row.config)
242
+ }
243
+ }
244
+ indexTargets(entries)
245
+ return await buildConfigSchemaDocument(profile.name, result.entries, targets, result.diagnostics)
246
+ } finally {
247
+ interception.dispose()
248
+ }
249
+ }
@@ -0,0 +1,169 @@
1
+ /** Compose Loader entry/patch structure and discovered plugin input schemas into one JSON Schema document. */
2
+
3
+ import { createConfigProjector, LOADER_EXPRESSION_SCHEMA } from './projector.ts'
4
+ import type {
5
+ CollectedConfigEntry, ConfigJsonSchema, ConfigJsonSchemaObject, ConfigSchemaDiagnostic,
6
+ ConfigSchemaDump, ConfigSchemaEntry, NativeConfigSchema,
7
+ } from './types.ts'
8
+
9
+ const ref = (name: string): ConfigJsonSchemaObject => ({ $ref: `#/$defs/${name}` })
10
+
11
+ function metadata(): Record<string, ConfigJsonSchema> {
12
+ return {
13
+ id: { type: 'string', description: 'Entry id. Loader generates an id when an entry omits it; patches use the configured id.' },
14
+ name: { type: 'string', description: 'Plugin module specifier. Inserted relative plugin paths are anchored beside their patch file.' },
15
+ config: {},
16
+ group: { type: ['boolean', 'null'], description: 'Allows patch indexing and insertion into an entry-list config; does not select the plugin implementation.' },
17
+ disabled: { anyOf: [{ type: ['boolean', 'null'] }, ref('loaderExpression')], description: 'Boolean or !!js expression. The Loader coerces other truthy values as disabled; this schema rejects them.' },
18
+ inject: { anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'object' }, { type: 'null' }] },
19
+ intercept: { type: ['object', 'null'] },
20
+ isolate: { type: ['object', 'null'], additionalProperties: { anyOf: [{ const: true }, { type: 'string' }] } },
21
+ }
22
+ }
23
+
24
+ function patchStructure(): ConfigJsonSchemaObject {
25
+ return {
26
+ type: 'object',
27
+ allOf: [ref('entryMetadata')],
28
+ properties: { insert: ref('entryList') },
29
+ description: 'An insert appends entries, optionally inside the group identified by id. Other patches replace supplied fields; config is replaced wholesale, not deep-merged. A truthy name asserts the existing plugin name rather than renaming it. Unknown targets and non-insert patches without a nonempty id are warned and skipped.',
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Build a schema for the composed entry list and a separately addressable root-tree patch list.
35
+ * Unknown plugin names remain open; only collected schemas supply plugin-specific constraints.
36
+ * @param profile - selected profile name.
37
+ * @param collected - declarations discovered without mounting plugins, including include descendants.
38
+ * @param targets - last-id-wins patch targets from the root tree's patch index, excluding include descendants.
39
+ * @param initialDiagnostics - composition and import diagnostics already collected.
40
+ * @returns one JSON Schema document with explicit collection and projection annotations.
41
+ */
42
+ export async function buildConfigSchemaDocument(
43
+ profile: string,
44
+ collected: readonly CollectedConfigEntry[],
45
+ targets: ReadonlyMap<string, CollectedConfigEntry>,
46
+ initialDiagnostics: readonly ConfigSchemaDiagnostic[],
47
+ ): Promise<ConfigSchemaDump> {
48
+ const project = await createConfigProjector()
49
+ const diagnostics = [...initialDiagnostics]
50
+ const definitions: Record<string, ConfigJsonSchema> = {
51
+ loaderExpression: LOADER_EXPRESSION_SCHEMA,
52
+ entryMetadata: { type: 'object', properties: metadata() },
53
+ entryList: { type: 'array', items: ref('entry') },
54
+ patchList: { type: 'array', items: ref('patch') },
55
+ unknownConfig: { $comment: 'No projected Config schema is available; this is unknown configuration, not a prohibition on fields.' },
56
+ includeConfig: {
57
+ type: 'object', required: ['path'],
58
+ properties: {
59
+ path: { type: 'string', description: 'YAML/JSON filename resolved relative to the owning Loader tree. This field is literal, not a !!js expression.' },
60
+ initial: ref('entryList'),
61
+ patches: { type: 'array', items: ref('includePatch') },
62
+ enableLogs: { type: 'boolean' },
63
+ },
64
+ description: 'Native Include configuration stays literal. initial is used only when the file is absent. Included files have their own module-resolution base and patch target index.',
65
+ },
66
+ includePatch: patchStructure(),
67
+ }
68
+ const entries: ConfigSchemaEntry[] = []
69
+ const outputByEntry = new Map<CollectedConfigEntry, ConfigSchemaEntry>()
70
+ const projections = new Map<NativeConfigSchema, { reference: string; required: boolean; limitations: string[] }>()
71
+ const trees = new Set<string>(['#/$defs/entryList', '#/$defs/includeConfig'])
72
+ const requiredConfigs = new Set(trees)
73
+ const names = new Map<string, Set<string>>([
74
+ ['cordis:group', new Set(['#/$defs/entryList'])],
75
+ ['cordis:include', new Set(['#/$defs/includeConfig'])],
76
+ ])
77
+
78
+ for (const entry of collected) {
79
+ const { native, ...output } = entry
80
+ if (entry.tree) output.configRef = entry.tree === 'group' ? '#/$defs/entryList' : '#/$defs/includeConfig'
81
+ else if (native) {
82
+ let projection = projections.get(native)
83
+ if (projection === undefined) {
84
+ const name = `config${projections.size}`
85
+ try {
86
+ const result = project(native, name)
87
+ definitions[name] = result.schema
88
+ Object.assign(definitions, result.definitions)
89
+ projection = { reference: `#/$defs/${name}`, required: result.acceptsMissing === false, limitations: result.limitations }
90
+ projections.set(native, projection)
91
+ } catch (error) {
92
+ output.status = 'error'
93
+ diagnostics.push({ level: 'error', path: entry.path, message: error instanceof Error ? error.message : String(error) })
94
+ }
95
+ }
96
+ if (projection) {
97
+ output.configRef = projection.reference
98
+ output.status = projection.limitations.length ? 'partial' : 'schema'
99
+ if (projection.required) requiredConfigs.add(projection.reference)
100
+ for (const message of projection.limitations) diagnostics.push({ level: 'warning', path: entry.path, message })
101
+ }
102
+ }
103
+ output.configRef ??= '#/$defs/unknownConfig'
104
+ entries.push(output)
105
+ outputByEntry.set(entry, output)
106
+ if (entry.name !== undefined) {
107
+ const choices = names.get(entry.name) ?? new Set<string>()
108
+ choices.add(output.configRef)
109
+ names.set(entry.name, choices)
110
+ }
111
+ }
112
+
113
+ // The Loader never initializes a disabled row unless group: true forces it, so its required config may be omitted.
114
+ const dormant: ConfigJsonSchemaObject = {
115
+ properties: { disabled: { anyOf: [{ const: true }, ref('loaderExpression')] }, group: { not: { const: true } } },
116
+ required: ['disabled'],
117
+ }
118
+ const entryRules: ConfigJsonSchema[] = []
119
+ for (const [name, choices] of names) {
120
+ const validated: ConfigJsonSchemaObject = { properties: { config: { anyOf: [...choices].map(reference => ({ $ref: reference })) } } }
121
+ let then = validated
122
+ if ([...choices].every(reference => requiredConfigs.has(reference))) {
123
+ then = { if: dormant, else: { ...validated, required: ['config'] } }
124
+ // A dormant carrier never creates its children, so their declarations are not validated either.
125
+ if (![...choices].every(reference => trees.has(reference))) then.then = validated
126
+ }
127
+ entryRules.push({ if: { properties: { name: { const: name } }, required: ['name'] }, then })
128
+ if (choices.size > 1) {
129
+ diagnostics.push({ level: 'warning', message: `Plugin ${JSON.stringify(name)} has multiple collected schemas; entry validation accepts their union because module resolution depends on the owning tree.` })
130
+ }
131
+ }
132
+ definitions.entry = {
133
+ type: 'object', required: ['name'], allOf: [ref('entryMetadata'), ...entryRules],
134
+ description: 'Loader entry. Unknown metadata and plugin names are accepted; plugin-specific validation is available only for the names collected in this profile. Distinct resolutions of one name use a union of their schemas.',
135
+ }
136
+
137
+ const patchRules: ConfigJsonSchema[] = []
138
+ for (const [id, target] of targets) {
139
+ if (target.name === undefined) continue
140
+ const configRef = outputByEntry.get(target)?.configRef ?? '#/$defs/unknownConfig'
141
+ patchRules.push({
142
+ if: {
143
+ required: ['id'], properties: { id: { const: id } },
144
+ allOf: [
145
+ { not: { required: ['insert'] } },
146
+ { anyOf: [{ not: { required: ['name'] } }, { properties: { name: { enum: ['', target.name] } } }] },
147
+ ],
148
+ },
149
+ then: { properties: { config: { $ref: configRef } } },
150
+ })
151
+ }
152
+ definitions.patch = { ...patchStructure(), allOf: [ref('entryMetadata'), ...patchRules] }
153
+ const positions = new Map(entries.map((entry, index) => [entry.path, index]))
154
+ diagnostics.sort((left, right) => (positions.get(left.path ?? '') ?? -1) - (positions.get(right.path ?? '') ?? -1))
155
+ return {
156
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
157
+ title: `Cordis configuration for profile ${profile}`,
158
+ description: 'Describes the parsed entry-list YAML printed by --dump-config. Use $defs.patchList for a profile/home/CLI overlay. Parse !!js with the Cordis entry-list YAML dialect. Expression results, service dependencies, plugin startup checks, and sequence-dependent patch targets still require runtime validation.',
159
+ $comment: 'Bundle, profile, home, and CLI layers apply in that order. A patch config replaces the whole config. JSON Schema defaults are annotations; Schemastery validates a fallback on null/omission unless required rejects it first. Schemastery unions select the first successful branch. Root patch id constraints describe the current index, not ids introduced or changed by earlier patches. Include-local patches have a separate index. Relative plugin insertions require their source patch directory; this document does not infer future overlay locations.',
160
+ type: 'array', items: ref('entry'), $defs: definitions,
161
+ 'x-cordis': {
162
+ profile,
163
+ complete: !diagnostics.some(item => item.level === 'error')
164
+ && !entries.some(entry => ['partial', 'unsupported', 'error'].includes(entry.status))
165
+ && ![...names.values()].some(choices => choices.size > 1),
166
+ entries, diagnostics, patchSchema: '#/$defs/patchList',
167
+ },
168
+ }
169
+ }
@@ -0,0 +1,36 @@
1
+ /** Profile schema generation: composition diagnostics, runtime resolution, and boot-free discovery. */
2
+
3
+ import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
4
+ import { composeEntries, createRuntimeResolution, type Profile } from '../profile.ts'
5
+ import { collectConfigSchemas } from './collect.ts'
6
+ import type { ConfigSchemaDiagnostic, ConfigSchemaDump } from './types.ts'
7
+ export type { ConfigSchemaDump, NativeConfigSchema } from './types.ts'
8
+
9
+ /**
10
+ * Generate JSON Schema for a prepared profile's ordered patch layers without mounting plugins or evaluating expressions.
11
+ * Imports, Config getters, and lazy builders execute trusted code. Native validators and
12
+ * transform callbacks are not executed. Calls must not overlap another profile-resolution interception; collection
13
+ * releases its interception on success or rejection, while Node retains imported modules. Supplied layers are not mutated.
14
+ * Profile preparation, layer selection, process streams, and exit policy belong to the caller.
15
+ * @param profile - prepared on-disk profile whose directory anchors root module and include resolution.
16
+ * @param layers - already parsed patch lists in application order, including caller-selected home and argv overlays.
17
+ * @param installAnchor - package manifest anchoring the installation's runtime dependencies.
18
+ * @returns a JSON Schema document with declaration references, partial results, and diagnostics under `x-cordis`.
19
+ * @throws when composition or runtime resolution cannot be prepared.
20
+ */
21
+ export async function generateConfigSchema(
22
+ profile: Profile,
23
+ layers: readonly PatchOptions[][],
24
+ installAnchor: string,
25
+ ): Promise<ConfigSchemaDump> {
26
+ const diagnostics: ConfigSchemaDiagnostic[] = []
27
+ for (const { packageName } of profile.skippedBundles) {
28
+ diagnostics.push({
29
+ level: 'error',
30
+ message: `Selected profile bundle ${JSON.stringify(packageName)} could not be loaded; repair or remove its bundle selection.`,
31
+ })
32
+ }
33
+ const entries = composeEntries(layers, message => diagnostics.push({ level: 'warning', message }))
34
+ const resolution = await createRuntimeResolution({ installAnchor, profile })
35
+ return collectConfigSchemas(profile, entries, resolution, diagnostics)
36
+ }
@@ -0,0 +1,16 @@
1
+ /** Identity checks at the plugin-export and lazy-builder boundaries. */
2
+
3
+ import type { NativeConfigSchema } from './types.ts'
4
+
5
+ /**
6
+ * Recognize the native Schemastery graph protocol without invoking a validator or serialization hook.
7
+ * @param value - plugin Config export or lazy-builder result.
8
+ * @returns whether native schema fields can be inspected.
9
+ */
10
+ export function isNativeConfigSchema(value: unknown): value is NativeConfigSchema {
11
+ if (value === null || (typeof value !== 'object' && typeof value !== 'function')) return false
12
+ const meta: unknown = Reflect.get(value, 'meta')
13
+ return Reflect.get(value, Symbol.for('schemastery')) === true
14
+ && typeof Reflect.get(value, 'type') === 'string'
15
+ && meta !== null && typeof meta === 'object'
16
+ }
@@ -0,0 +1,62 @@
1
+ /** Conservative compatibility checks between native RegExp validation and Unicode JSON Schema patterns. */
2
+
3
+ import type { AST } from '@eslint-community/regexpp'
4
+
5
+ function bmpOnly(node: AST.Node): boolean {
6
+ switch (node.type) {
7
+ case 'Character': return node.value <= 0xffff && (node.value < 0xd800 || node.value > 0xdfff)
8
+ case 'CharacterClassRange': return bmpOnly(node.min) && bmpOnly(node.max) && (node.max.value < 0xd800 || node.min.value > 0xdfff)
9
+ case 'CharacterClass': return !node.negate && node.elements.every(bmpOnly)
10
+ case 'CharacterSet': return (node.kind === 'digit' || node.kind === 'space' || node.kind === 'word') && !node.negate
11
+ case 'Quantifier': return bmpOnly(node.element)
12
+ case 'Assertion': return ['start', 'end', 'word'].includes(node.kind)
13
+ default: return false
14
+ }
15
+ }
16
+
17
+ function broadSet(node: AST.Node): boolean {
18
+ return node.type === 'CharacterSet' && (node.kind === 'any'
19
+ || ((node.kind === 'digit' || node.kind === 'space' || node.kind === 'word') && node.negate))
20
+ }
21
+
22
+ function portableAlternative(alternative: AST.Alternative): boolean {
23
+ // A lone, unanchored broad set asks only whether a matching character exists.
24
+ if (alternative.elements.length === 1 && alternative.elements.some(broadSet)) return true
25
+ let broadRepetitions = 0
26
+ const wordAssertion = alternative.elements.some(element => element.type === 'Assertion' && element.kind === 'word')
27
+ return alternative.elements.every((element) => {
28
+ if (element.type === 'Quantifier' && element.max === Infinity && element.min <= 1 && broadSet(element.element)) {
29
+ // Word assertions and multiple broad groups can observe a surrogate pair's midpoint.
30
+ return !wordAssertion && ++broadRepetitions === 1
31
+ }
32
+ return bmpOnly(element)
33
+ })
34
+ }
35
+
36
+ function signature(pattern: AST.Pattern): string {
37
+ const incidental = new Set(['parent', 'start', 'end', 'raw', 'references', 'resolved'])
38
+ return JSON.stringify(pattern, (key: string, value: unknown) => incidental.has(key) ? undefined : value)
39
+ }
40
+
41
+ /**
42
+ * Create a checker for patterns that can retain native acceptance under Unicode regex semantics.
43
+ * Unrecognized flagless syntax stays explicitly partial rather than becoming a misleading constraint.
44
+ * @returns the compatibility predicate; parsing never executes the regular expression against config values.
45
+ */
46
+ export async function createPatternCheck(): Promise<(source: string, flags?: string) => boolean> {
47
+ const { RegExpParser } = await import('@eslint-community/regexpp')
48
+ const parser = new RegExpParser()
49
+ return (source, flags = '') => {
50
+ if (flags !== '' && flags !== 'u') return false
51
+ try {
52
+ const unicode = parser.parsePattern(source, 0, source.length, { unicode: true })
53
+ if (flags === 'u') return true
54
+ const native = parser.parsePattern(source, 0, source.length, { unicode: false })
55
+ return signature(native) === signature(unicode) && native.alternatives.every(portableAlternative)
56
+ } catch (error) {
57
+ // Invalid Unicode syntax cannot be emitted as a standard pattern constraint.
58
+ if (error instanceof SyntaxError) return false
59
+ throw error
60
+ }
61
+ }
62
+ }