@stonecrop/stonecrop 0.6.3 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stonecrop/stonecrop",
3
- "version": "0.6.3",
3
+ "version": "0.7.0",
4
4
  "description": "schema helper",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -35,33 +35,33 @@
35
35
  "src/*"
36
36
  ],
37
37
  "dependencies": {
38
- "@vueuse/core": "^14.0.0",
38
+ "@vueuse/core": "^14.1.0",
39
39
  "immutable": "^5.1.4",
40
40
  "pinia-shared-state": "^1.0.1",
41
41
  "pinia-xstate": "^3.0.0",
42
- "pinia": "^3.0.3",
43
- "vue-router": "^4.6.3",
44
- "vue": "^3.5.22",
45
- "xstate": "^5.20.1"
42
+ "pinia": "^3.0.4",
43
+ "vue-router": "^4.6.4",
44
+ "vue": "^3.5.26",
45
+ "xstate": "^5.25.0"
46
46
  },
47
47
  "devDependencies": {
48
- "@eslint/js": "^9.38.0",
49
- "@microsoft/api-documenter": "^7.27.3",
50
- "@rushstack/heft": "^1.1.3",
51
- "@vitejs/plugin-vue": "^6.0.1",
52
- "@vitest/coverage-istanbul": "^4.0.5",
48
+ "@eslint/js": "^9.39.2",
49
+ "@microsoft/api-documenter": "^7.28.2",
50
+ "@rushstack/heft": "^1.1.7",
51
+ "@vitejs/plugin-vue": "^6.0.3",
52
+ "@vitest/coverage-istanbul": "^4.0.17",
53
53
  "@vue/test-utils": "^2.4.6",
54
- "eslint": "^9.38.0",
54
+ "eslint": "^9.39.2",
55
55
  "eslint-config-prettier": "^10.1.8",
56
- "eslint-plugin-vue": "^10.5.1",
57
- "globals": "^16.4.0",
58
- "jsdom": "^27.1.0",
56
+ "eslint-plugin-vue": "^10.6.2",
57
+ "globals": "^17.0.0",
58
+ "jsdom": "^27.4.0",
59
59
  "typescript": "^5.9.3",
60
- "typescript-eslint": "^8.46.2",
61
- "vite": "^7.1.1",
62
- "vitest": "^4.0.5",
63
- "@stonecrop/aform": "0.6.3",
64
- "@stonecrop/atable": "0.6.3",
60
+ "typescript-eslint": "^8.53.0",
61
+ "vite": "^7.3.1",
62
+ "vitest": "^4.0.17",
63
+ "@stonecrop/aform": "0.7.0",
64
+ "@stonecrop/atable": "0.7.0",
65
65
  "stonecrop-rig": "0.2.22"
66
66
  },
67
67
  "publishConfig": {
package/src/index.ts CHANGED
@@ -17,6 +17,8 @@ import Registry from './registry'
17
17
  import { Stonecrop } from './stonecrop'
18
18
  import { HST, createHST, type HSTNode } from './stores/hst'
19
19
  import { useOperationLogStore } from './stores/operation-log'
20
+ // Export schema validator
21
+ import { SchemaValidator, createValidator, validateSchema } from './schema-validator'
20
22
  export type * from './types'
21
23
  export type { BaseStonecropReturn, HSTChangeData, HSTStonecropReturn, OperationLogAPI } from './composable'
22
24
  export type { FieldTriggerEngine } from './field-triggers'
@@ -29,6 +31,9 @@ export type {
29
31
  FieldActionFunction,
30
32
  TransitionActionFunction,
31
33
  } from './types/field-triggers'
34
+ // Export schema validator types
35
+ export type { ValidationIssue, ValidationResult, ValidatorOptions } from './schema-validator'
36
+ export { ValidationSeverity } from './schema-validator'
32
37
 
33
38
  export {
34
39
  DoctypeMeta,
@@ -46,6 +51,10 @@ export {
46
51
  setFieldRollback,
47
52
  triggerTransition,
48
53
  markOperationIrreversible,
54
+ // Schema validator exports
55
+ SchemaValidator,
56
+ createValidator,
57
+ validateSchema,
49
58
  // Operation log exports
50
59
  useOperationLog,
51
60
  useOperationLogStore,
@@ -0,0 +1,427 @@
1
+ /**
2
+ * Schema Validation Utilities
3
+ * Validates Stonecrop schemas for integrity and consistency
4
+ * @packageDocumentation
5
+ */
6
+
7
+ import type { SchemaTypes } from '@stonecrop/aform'
8
+ import type { List, Map as ImmutableMap } from 'immutable'
9
+ import type { AnyStateNodeConfig } from 'xstate'
10
+ import { getGlobalTriggerEngine } from './field-triggers'
11
+ import type Registry from './registry'
12
+
13
+ /**
14
+ * Validation severity levels
15
+ * @public
16
+ */
17
+ export enum ValidationSeverity {
18
+ /** Blocking error that prevents save */
19
+ ERROR = 'error',
20
+ /** Advisory warning that allows save */
21
+ WARNING = 'warning',
22
+ /** Informational message */
23
+ INFO = 'info',
24
+ }
25
+
26
+ /**
27
+ * Validation issue
28
+ * @public
29
+ */
30
+ export interface ValidationIssue {
31
+ /** Severity level */
32
+ severity: ValidationSeverity
33
+ /** Validation rule that failed */
34
+ rule: string
35
+ /** Human-readable message */
36
+ message: string
37
+ /** Doctype name */
38
+ doctype?: string
39
+ /** Field name if applicable */
40
+ fieldname?: string
41
+ /** Additional context */
42
+ context?: Record<string, unknown>
43
+ }
44
+
45
+ /**
46
+ * Validation result
47
+ * @public
48
+ */
49
+ export interface ValidationResult {
50
+ /** Whether validation passed (no blocking errors) */
51
+ valid: boolean
52
+ /** List of validation issues */
53
+ issues: ValidationIssue[]
54
+ /** Count of errors */
55
+ errorCount: number
56
+ /** Count of warnings */
57
+ warningCount: number
58
+ /** Count of info messages */
59
+ infoCount: number
60
+ }
61
+
62
+ /**
63
+ * Schema validator options
64
+ * @public
65
+ */
66
+ export interface ValidatorOptions {
67
+ /** Registry instance for doctype lookups */
68
+ registry?: Registry
69
+ /** Whether to validate Link field targets */
70
+ validateLinkTargets?: boolean
71
+ /** Whether to validate workflow reachability */
72
+ validateWorkflows?: boolean
73
+ /** Whether to validate action registration */
74
+ validateActions?: boolean
75
+ /** Whether to validate required schema properties */
76
+ validateRequiredProperties?: boolean
77
+ }
78
+
79
+ /**
80
+ * Schema validator class
81
+ * @public
82
+ */
83
+ export class SchemaValidator {
84
+ private options: Required<ValidatorOptions>
85
+
86
+ /**
87
+ * Creates a new SchemaValidator instance
88
+ * @param options - Validator configuration options
89
+ */
90
+ constructor(options: ValidatorOptions = {}) {
91
+ this.options = {
92
+ registry: options.registry || null!,
93
+ validateLinkTargets: options.validateLinkTargets ?? true,
94
+ validateActions: options.validateActions ?? true,
95
+ validateWorkflows: options.validateWorkflows ?? true,
96
+ validateRequiredProperties: options.validateRequiredProperties ?? true,
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Validates a complete doctype schema
102
+ * @param doctype - Doctype name
103
+ * @param schema - Schema fields (List or Array)
104
+ * @param workflow - Optional workflow configuration
105
+ * @param actions - Optional actions map
106
+ * @returns Validation result
107
+ */
108
+ validate(
109
+ doctype: string,
110
+ schema: List<SchemaTypes> | SchemaTypes[] | undefined,
111
+ workflow?: AnyStateNodeConfig,
112
+ actions?: ImmutableMap<string, string[]> | Map<string, string[]>
113
+ ): ValidationResult {
114
+ const issues: ValidationIssue[] = []
115
+
116
+ // Convert schema to array for easier iteration
117
+ const schemaArray = schema ? (Array.isArray(schema) ? schema : schema.toArray()) : []
118
+
119
+ // Validate required properties
120
+ if (this.options.validateRequiredProperties) {
121
+ issues.push(...this.validateRequiredProperties(doctype, schemaArray))
122
+ }
123
+
124
+ // Validate Link field targets
125
+ if (this.options.validateLinkTargets && this.options.registry) {
126
+ issues.push(...this.validateLinkFields(doctype, schemaArray, this.options.registry))
127
+ }
128
+
129
+ // Validate workflow configuration
130
+ if (this.options.validateWorkflows && workflow) {
131
+ issues.push(...this.validateWorkflow(doctype, workflow))
132
+ }
133
+
134
+ // Validate action registration
135
+ if (this.options.validateActions && actions) {
136
+ const actionsMap = actions instanceof Map ? actions : actions.toObject()
137
+ issues.push(...this.validateActionRegistration(doctype, actionsMap as Record<string, string[]>))
138
+ }
139
+
140
+ // Calculate counts
141
+ const errorCount = issues.filter(i => i.severity === ValidationSeverity.ERROR).length
142
+ const warningCount = issues.filter(i => i.severity === ValidationSeverity.WARNING).length
143
+ const infoCount = issues.filter(i => i.severity === ValidationSeverity.INFO).length
144
+
145
+ return {
146
+ valid: errorCount === 0,
147
+ issues,
148
+ errorCount,
149
+ warningCount,
150
+ infoCount,
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Validates that required schema properties are present
156
+ * @internal
157
+ */
158
+ private validateRequiredProperties(doctype: string, schema: SchemaTypes[]): ValidationIssue[] {
159
+ const issues: ValidationIssue[] = []
160
+
161
+ for (const field of schema) {
162
+ // Check for fieldname
163
+ if (!field.fieldname) {
164
+ issues.push({
165
+ severity: ValidationSeverity.ERROR,
166
+ rule: 'required-fieldname',
167
+ message: 'Field is missing required property: fieldname',
168
+ doctype,
169
+ context: { field },
170
+ })
171
+ continue
172
+ }
173
+
174
+ // Check for component or fieldtype
175
+ if (!field.component && !('fieldtype' in field)) {
176
+ issues.push({
177
+ severity: ValidationSeverity.ERROR,
178
+ rule: 'required-component-or-fieldtype',
179
+ message: `Field "${field.fieldname}" must have either component or fieldtype property`,
180
+ doctype,
181
+ fieldname: field.fieldname,
182
+ })
183
+ }
184
+
185
+ // Validate nested schemas (recursively)
186
+ if ('schema' in field) {
187
+ const nestedSchema = (field as { schema: unknown }).schema
188
+ const nestedArray = (
189
+ Array.isArray(nestedSchema) ? nestedSchema : (nestedSchema as { toArray?: () => unknown[] }).toArray?.() || []
190
+ ) as SchemaTypes[]
191
+ issues.push(...this.validateRequiredProperties(doctype, nestedArray))
192
+ }
193
+ }
194
+
195
+ return issues
196
+ }
197
+
198
+ /**
199
+ * Validates Link field targets exist in registry
200
+ * @internal
201
+ */
202
+ private validateLinkFields(doctype: string, schema: SchemaTypes[], registry: Registry): ValidationIssue[] {
203
+ const issues: ValidationIssue[] = []
204
+
205
+ for (const field of schema) {
206
+ const fieldtype = 'fieldtype' in field ? (field as { fieldtype: unknown }).fieldtype : undefined
207
+
208
+ // Check Link fields
209
+ if (fieldtype === 'Link') {
210
+ const options = 'options' in field ? (field as { options: unknown }).options : undefined
211
+ if (!options) {
212
+ issues.push({
213
+ severity: ValidationSeverity.ERROR,
214
+ rule: 'link-missing-options',
215
+ message: `Link field "${field.fieldname}" is missing options property (target doctype)`,
216
+ doctype,
217
+ fieldname: field.fieldname,
218
+ })
219
+ continue
220
+ }
221
+
222
+ // Check if target doctype exists in registry
223
+ // Options should be a string representing the target doctype name
224
+ const targetDoctype = typeof options === 'string' ? options : ''
225
+ if (!targetDoctype) {
226
+ issues.push({
227
+ severity: ValidationSeverity.ERROR,
228
+ rule: 'link-invalid-options',
229
+ message: `Link field "${field.fieldname}" has invalid options format (expected string doctype name)`,
230
+ doctype,
231
+ fieldname: field.fieldname,
232
+ })
233
+ continue
234
+ }
235
+ const targetMeta = registry.registry[targetDoctype] || registry.registry[targetDoctype.toLowerCase()]
236
+
237
+ if (!targetMeta) {
238
+ issues.push({
239
+ severity: ValidationSeverity.ERROR,
240
+ rule: 'link-invalid-target',
241
+ message: `Link field "${field.fieldname}" references non-existent doctype: "${targetDoctype}"`,
242
+ doctype,
243
+ fieldname: field.fieldname,
244
+ context: { targetDoctype },
245
+ })
246
+ }
247
+ }
248
+
249
+ // Recursively check nested schemas
250
+ if ('schema' in field) {
251
+ const nestedSchema = (field as { schema: unknown }).schema
252
+ const nestedArray = (
253
+ Array.isArray(nestedSchema) ? nestedSchema : (nestedSchema as { toArray?: () => unknown[] }).toArray?.() || []
254
+ ) as SchemaTypes[]
255
+ issues.push(...this.validateLinkFields(doctype, nestedArray, registry))
256
+ }
257
+ }
258
+
259
+ return issues
260
+ }
261
+
262
+ /**
263
+ * Validates workflow state machine configuration
264
+ * @internal
265
+ */
266
+ private validateWorkflow(doctype: string, workflow: AnyStateNodeConfig): ValidationIssue[] {
267
+ const issues: ValidationIssue[] = []
268
+
269
+ // Check for initial state
270
+ if (!workflow.initial && !workflow.type) {
271
+ issues.push({
272
+ severity: ValidationSeverity.WARNING,
273
+ rule: 'workflow-missing-initial',
274
+ message: 'Workflow is missing initial state property',
275
+ doctype,
276
+ })
277
+ }
278
+
279
+ // Check for states
280
+ if (!workflow.states || Object.keys(workflow.states).length === 0) {
281
+ issues.push({
282
+ severity: ValidationSeverity.WARNING,
283
+ rule: 'workflow-no-states',
284
+ message: 'Workflow has no states defined',
285
+ doctype,
286
+ })
287
+ return issues
288
+ }
289
+
290
+ // Validate initial state exists
291
+ if (workflow.initial && typeof workflow.initial === 'string' && !workflow.states[workflow.initial]) {
292
+ issues.push({
293
+ severity: ValidationSeverity.ERROR,
294
+ rule: 'workflow-invalid-initial',
295
+ message: `Workflow initial state "${workflow.initial}" does not exist in states`,
296
+ doctype,
297
+ context: { initialState: workflow.initial },
298
+ })
299
+ }
300
+
301
+ // Check state reachability (simple check - all states should have at least one incoming transition or be initial)
302
+ const stateNames = Object.keys(workflow.states)
303
+ const reachableStates = new Set<string>()
304
+
305
+ // Initial state is always reachable
306
+ if (workflow.initial && typeof workflow.initial === 'string') {
307
+ reachableStates.add(workflow.initial)
308
+ }
309
+
310
+ // Find all target states from transitions
311
+ for (const [_stateName, stateConfig] of Object.entries(workflow.states)) {
312
+ const state = stateConfig as AnyStateNodeConfig
313
+ if (state.on) {
314
+ for (const [_event, transition] of Object.entries(state.on)) {
315
+ if (typeof transition === 'string') {
316
+ reachableStates.add(transition)
317
+ } else if (transition && typeof transition === 'object') {
318
+ const target = 'target' in transition ? (transition as { target: unknown }).target : undefined
319
+ if (typeof target === 'string') {
320
+ reachableStates.add(target)
321
+ } else if (Array.isArray(target)) {
322
+ target.forEach((t: unknown) => {
323
+ if (typeof t === 'string') {
324
+ reachableStates.add(t)
325
+ }
326
+ })
327
+ }
328
+ }
329
+ }
330
+ }
331
+ }
332
+
333
+ // Check for unreachable states
334
+ for (const stateName of stateNames) {
335
+ if (!reachableStates.has(stateName)) {
336
+ issues.push({
337
+ severity: ValidationSeverity.WARNING,
338
+ rule: 'workflow-unreachable-state',
339
+ message: `Workflow state "${stateName}" may not be reachable`,
340
+ doctype,
341
+ context: { stateName },
342
+ })
343
+ }
344
+ }
345
+
346
+ return issues
347
+ }
348
+
349
+ /**
350
+ * Validates that actions are registered in the FieldTriggerEngine
351
+ * @internal
352
+ */
353
+ private validateActionRegistration(doctype: string, actions: Record<string, string[]>): ValidationIssue[] {
354
+ const issues: ValidationIssue[] = []
355
+ const triggerEngine = getGlobalTriggerEngine()
356
+
357
+ for (const [triggerName, actionNames] of Object.entries(actions)) {
358
+ if (!Array.isArray(actionNames)) {
359
+ issues.push({
360
+ severity: ValidationSeverity.ERROR,
361
+ rule: 'action-invalid-format',
362
+ message: `Action configuration for "${triggerName}" must be an array`,
363
+ doctype,
364
+ context: { triggerName, actionNames },
365
+ })
366
+ continue
367
+ }
368
+
369
+ // Check each action name
370
+ for (const actionName of actionNames) {
371
+ // Check if action is registered globally
372
+ const engine = triggerEngine as unknown as {
373
+ globalActions?: Map<string, unknown>
374
+ globalTransitionActions?: Map<string, unknown>
375
+ }
376
+ const isRegistered = engine.globalActions?.has(actionName) || engine.globalTransitionActions?.has(actionName)
377
+
378
+ if (!isRegistered) {
379
+ issues.push({
380
+ severity: ValidationSeverity.WARNING,
381
+ rule: 'action-not-registered',
382
+ message: `Action "${actionName}" referenced in "${triggerName}" is not registered in FieldTriggerEngine`,
383
+ doctype,
384
+ context: { triggerName, actionName },
385
+ })
386
+ }
387
+ }
388
+ }
389
+
390
+ return issues
391
+ }
392
+ }
393
+
394
+ /**
395
+ * Creates a validator with the given registry
396
+ * @param registry - Registry instance
397
+ * @param options - Additional validator options
398
+ * @returns SchemaValidator instance
399
+ * @public
400
+ */
401
+ export function createValidator(registry: Registry, options?: Partial<ValidatorOptions>): SchemaValidator {
402
+ return new SchemaValidator({
403
+ registry,
404
+ ...options,
405
+ })
406
+ }
407
+
408
+ /**
409
+ * Quick validation helper
410
+ * @param doctype - Doctype name
411
+ * @param schema - Schema fields
412
+ * @param registry - Registry instance
413
+ * @param workflow - Optional workflow configuration
414
+ * @param actions - Optional actions map
415
+ * @returns Validation result
416
+ * @public
417
+ */
418
+ export function validateSchema(
419
+ doctype: string,
420
+ schema: List<SchemaTypes> | SchemaTypes[] | undefined,
421
+ registry: Registry,
422
+ workflow?: AnyStateNodeConfig,
423
+ actions?: ImmutableMap<string, string[]> | Map<string, string[]>
424
+ ): ValidationResult {
425
+ const validator = createValidator(registry)
426
+ return validator.validate(doctype, schema, workflow, actions)
427
+ }