dsh-model-everything 0.0.0-stage → 0.2.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 (133) hide show
  1. package/LICENSE +21 -0
  2. package/README.en-US.md +332 -0
  3. package/README.md +332 -3
  4. package/cordis.patch.yml +20 -0
  5. package/examples/README.md +48 -0
  6. package/examples/control-dispatcher.json +113 -0
  7. package/examples/control-dispatcher.overbudget.json +113 -0
  8. package/examples/ecommerce-data-model.json +185 -0
  9. package/examples/order-state-machine.after.json +116 -0
  10. package/examples/order-state-machine.before.json +101 -0
  11. package/examples/user-migration.after.json +29 -0
  12. package/examples/user-migration.before.json +29 -0
  13. package/gate/locale/en.json +6 -0
  14. package/gate/locale/zh.json +6 -0
  15. package/gate/package.json +5 -0
  16. package/lib/baseline.js +249 -0
  17. package/lib/compose-tool.js +80 -0
  18. package/lib/concurrency-tool.js +40 -0
  19. package/lib/concurrency.js +206 -0
  20. package/lib/data-engine.js +1141 -0
  21. package/lib/data-tool.js +87 -0
  22. package/lib/design-engine.js +1149 -0
  23. package/lib/design-tool.js +63 -0
  24. package/lib/engine.js +4343 -0
  25. package/lib/export-tool.js +78 -0
  26. package/lib/exporters.js +434 -0
  27. package/lib/gate.js +274 -0
  28. package/lib/granularity.js +237 -0
  29. package/lib/index.js +237 -0
  30. package/lib/json-value.js +18 -0
  31. package/lib/model-file.js +48 -0
  32. package/lib/option-shape.js +62 -0
  33. package/lib/report-diff-tool.js +63 -0
  34. package/lib/report-view.js +433 -0
  35. package/lib/structure-tool.js +91 -0
  36. package/lib/structure.js +711 -0
  37. package/lib/tool.js +94 -0
  38. package/lib/types/baseline.d.ts +65 -0
  39. package/lib/types/compose-tool.d.ts +7 -0
  40. package/lib/types/concurrency-tool.d.ts +8 -0
  41. package/lib/types/concurrency.d.ts +37 -0
  42. package/lib/types/data-engine.d.ts +211 -0
  43. package/lib/types/data-tool.d.ts +10 -0
  44. package/lib/types/design-engine.d.ts +215 -0
  45. package/lib/types/design-tool.d.ts +11 -0
  46. package/lib/types/engine.d.ts +494 -0
  47. package/lib/types/export-tool.d.ts +7 -0
  48. package/lib/types/exporters.d.ts +37 -0
  49. package/lib/types/gate.d.ts +91 -0
  50. package/lib/types/granularity.d.ts +106 -0
  51. package/lib/types/index.d.ts +45 -0
  52. package/lib/types/json-value.d.ts +21 -0
  53. package/lib/types/model-file.d.ts +7 -0
  54. package/lib/types/option-shape.d.ts +22 -0
  55. package/lib/types/report-diff-tool.d.ts +8 -0
  56. package/lib/types/report-view.d.ts +50 -0
  57. package/lib/types/structure-tool.d.ts +10 -0
  58. package/lib/types/structure.d.ts +152 -0
  59. package/lib/types/tool.d.ts +11 -0
  60. package/lib/types/uml-communication.d.ts +109 -0
  61. package/lib/types/uml-composite.d.ts +107 -0
  62. package/lib/types/uml-interaction-overview.d.ts +91 -0
  63. package/lib/types/uml-profile.d.ts +112 -0
  64. package/lib/types/uml-structure.d.ts +53 -0
  65. package/lib/types/uml-syntax.d.ts +296 -0
  66. package/lib/types/uml-timing.d.ts +152 -0
  67. package/lib/types/uml-tool.d.ts +12 -0
  68. package/lib/types/uml-usecase.d.ts +58 -0
  69. package/lib/types/uml.d.ts +303 -0
  70. package/lib/uml-communication.js +353 -0
  71. package/lib/uml-composite.js +442 -0
  72. package/lib/uml-interaction-overview.js +277 -0
  73. package/lib/uml-profile.js +373 -0
  74. package/lib/uml-structure.js +306 -0
  75. package/lib/uml-syntax.js +545 -0
  76. package/lib/uml-timing.js +974 -0
  77. package/lib/uml-tool.js +213 -0
  78. package/lib/uml-usecase.js +532 -0
  79. package/lib/uml.js +2110 -0
  80. package/locale/en.json +6 -0
  81. package/locale/zh.json +6 -0
  82. package/package.json +128 -6
  83. package/skills/model-everything/SKILL.md +450 -0
  84. package/skills/model-everything/references/dsh-model-schema.md +437 -0
  85. package/skills/model-everything/references/gap-routing-guide.md +42 -0
  86. package/skills/model-everything/references/hash-spec.md +152 -0
  87. package/skills/model-everything/references/logic-verification-guide.md +422 -0
  88. package/skills/model-everything-datamodel/SKILL.md +130 -0
  89. package/skills/model-everything-datamodel/references/data-model-guide.md +84 -0
  90. package/skills/model-everything-datamodel/references/data-model-schema.md +128 -0
  91. package/skills/model-everything-design/SKILL.md +115 -0
  92. package/skills/model-everything-interleaving/SKILL.md +63 -0
  93. package/skills/model-everything-interleaving/SKILL.zh-CN.md +60 -0
  94. package/skills/model-everything-interleaving/references/concurrency-risk-guide.md +63 -0
  95. package/skills/model-everything-report-reading/SKILL.md +137 -0
  96. package/skills/model-everything-report-reading/references/reading-a-report.md +166 -0
  97. package/skills/model-everything-report-reading/references/reading-a-report.mmd +59 -0
  98. package/skills/model-everything-structure/SKILL.md +96 -0
  99. package/skills/model-everything-structure/references/structure-review-guide.md +201 -0
  100. package/skills/model-everything-uml/SKILL.md +60 -0
  101. package/skills/model-everything-uml/references/uml-modeling-guide.md +551 -0
  102. package/src/baseline.ts +288 -0
  103. package/src/compose-tool.ts +82 -0
  104. package/src/concurrency-tool.ts +44 -0
  105. package/src/concurrency.ts +254 -0
  106. package/src/data-engine.ts +1208 -0
  107. package/src/data-tool.ts +91 -0
  108. package/src/design-engine.ts +1263 -0
  109. package/src/design-tool.ts +68 -0
  110. package/src/engine.ts +4564 -0
  111. package/src/export-tool.ts +81 -0
  112. package/src/exporters.ts +399 -0
  113. package/src/gate.ts +330 -0
  114. package/src/granularity.ts +312 -0
  115. package/src/index.ts +289 -0
  116. package/src/json-value.ts +20 -0
  117. package/src/model-file.ts +49 -0
  118. package/src/option-shape.ts +64 -0
  119. package/src/report-diff-tool.ts +67 -0
  120. package/src/report-view.ts +483 -0
  121. package/src/structure-tool.ts +93 -0
  122. package/src/structure.ts +786 -0
  123. package/src/tool.ts +103 -0
  124. package/src/uml-communication.ts +428 -0
  125. package/src/uml-composite.ts +520 -0
  126. package/src/uml-interaction-overview.ts +321 -0
  127. package/src/uml-profile.ts +439 -0
  128. package/src/uml-structure.ts +317 -0
  129. package/src/uml-syntax.ts +764 -0
  130. package/src/uml-timing.ts +1106 -0
  131. package/src/uml-tool.ts +230 -0
  132. package/src/uml-usecase.ts +566 -0
  133. package/src/uml.ts +2139 -0
@@ -0,0 +1,1208 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { REPORT_SCHEMAS, verdictOfFindings, type CheckResult, type Finding, type ReportSchema, type Verdict } from './engine.js'
3
+
4
+ export const DATA_ENGINE_SCHEMA_VERSION = 1
5
+
6
+ export type DataValue = number | string | boolean | null
7
+
8
+ export type DataType =
9
+ | 'string'
10
+ | 'integer'
11
+ | 'number'
12
+ | 'boolean'
13
+ | 'uuid'
14
+ | 'date'
15
+ | 'datetime'
16
+ | 'timestamp'
17
+ | 'json'
18
+ | 'enum'
19
+ | 'array'
20
+ | 'object'
21
+ | 'binary'
22
+
23
+ export interface FieldSpec {
24
+ name: string
25
+ type: DataType | string
26
+ required?: boolean
27
+ unique?: boolean
28
+ nullable?: boolean
29
+ default?: DataValue
30
+ min?: number
31
+ max?: number
32
+ minLength?: number
33
+ maxLength?: number
34
+ pattern?: string
35
+ enum?: DataValue[]
36
+ ref?: string
37
+ items?: FieldSpec
38
+ monotonic?: 'inc' | 'dec'
39
+ }
40
+
41
+ export interface EntitySpec {
42
+ name: string
43
+ fields: FieldSpec[]
44
+ primaryKey?: string[]
45
+ uniqueKeys?: string[][]
46
+ indexes?: string[][]
47
+ }
48
+
49
+ export interface RelationshipSpec {
50
+ name?: string
51
+ fromEntity: string
52
+ fromField: string
53
+ toEntity: string
54
+ toField?: string
55
+ onDelete?: 'cascade' | 'restrict' | 'set-null' | 'no-action'
56
+ }
57
+
58
+ export type DataInvariantSpec =
59
+ | { id: string; description: string; kind: 'field-required'; entity: string; field: string }
60
+ | { id: string; description: string; kind: 'unique'; entity: string; fields: string[] }
61
+ | { id: string; description: string; kind: 'referential-integrity'; entity: string; field: string; refEntity: string; refField?: string }
62
+ | { id: string; description: string; kind: 'range'; entity: string; field: string; min?: number; max?: number }
63
+ | { id: string; description: string; kind: 'count-equal'; sourceEntity: string; targetEntity: string }
64
+ | { id: string; description: string; kind: 'field-equal'; sourceEntity: string; sourceField: string; targetEntity: string; targetField: string }
65
+ | { id: string; description: string; kind: 'no-orphan'; entity: string; field: string; refEntity: string }
66
+ | { id: string; description: string; kind: 'idempotent-copy'; sourceEntity: string; targetEntity: string }
67
+ | { id: string; description: string; kind: 'idempotent-migration'; from: string; to: string }
68
+ | { id: string; description: string; kind: 'monotonic'; entity: string; field: string; direction: 'inc' | 'dec' }
69
+ | { id: string; description: string; kind: 'sequence'; steps: string[] }
70
+ | { id: string; description: string; kind: 'leads-to'; entity: string; field: string; from: DataValue; to: DataValue }
71
+ | { id: string; description: string; kind: 'atomicity'; steps: string[] }
72
+
73
+ export interface DataModelV1 {
74
+ schemaVersion: 1
75
+ entities: EntitySpec[]
76
+ relationships?: RelationshipSpec[]
77
+ invariants?: DataInvariantSpec[]
78
+ boundaryChecks?: Array<{
79
+ entity: string
80
+ field: string
81
+ values?: DataValue[]
82
+ }>
83
+ }
84
+
85
+ export interface CopyPairSpec {
86
+ id: string
87
+ sourceEntity: string
88
+ targetEntity: string
89
+ mapping: Record<string, string>
90
+ }
91
+
92
+ export interface BackupPairSpec {
93
+ id: string
94
+ sourceEntity: string
95
+ targetEntity: string
96
+ mapping: Record<string, string>
97
+ }
98
+
99
+ export interface MigrationMappingSpec {
100
+ id?: string
101
+ from: string
102
+ to: string
103
+ transform?: 'copy' | 'rename' | 'split' | 'merge' | 'drop' | 'manual'
104
+ note?: string
105
+ }
106
+
107
+ export interface DataVerificationOptions {
108
+ beforeModel?: unknown
109
+ fieldMapping?: Record<string, string>
110
+ copyPairs?: CopyPairSpec[]
111
+ migrationMappings?: MigrationMappingSpec[]
112
+ backupPairs?: BackupPairSpec[]
113
+ }
114
+
115
+ export interface DataComparisonSummary {
116
+ beforeModelHash: string
117
+ afterModelHash: string
118
+ fieldMapping: Record<string, string>
119
+ beforeEntities: number
120
+ beforeFields: number
121
+ afterEntities: number
122
+ afterFields: number
123
+ addedEntities: string[]
124
+ removedEntities: string[]
125
+ addedFields: string[]
126
+ removedFields: string[]
127
+ addedRelationships: string[]
128
+ removedRelationships: string[]
129
+ }
130
+
131
+ export interface DataVerificationReport {
132
+ /** The engine ran and the input was well-formed enough to produce this report. Read `verdict` for the review outcome. */
133
+ ok: boolean
134
+ ran: boolean
135
+ verdict: Verdict
136
+ verdictReason: string
137
+ /** The versioned report contract this result follows. */
138
+ schema: ReportSchema
139
+ schemaVersion: 1
140
+ modelHash: string
141
+ /** Every hash this report carries, in one place. The data-model hash follows the same normalization as the published spec, but is not covered by it. */
142
+ hashes: { modelHash: string }
143
+ summary: {
144
+ entities: number
145
+ fields: number
146
+ errors: number
147
+ warnings: number
148
+ checksRun: number
149
+ truncated?: boolean
150
+ }
151
+ checks: CheckResult[]
152
+ comparison?: DataComparisonSummary
153
+ /** What to do next, derived from the findings — never empty. */
154
+ nextSteps: string[]
155
+ }
156
+
157
+ const KNOWN_TYPES = new Set<string>([
158
+ 'string', 'integer', 'number', 'boolean', 'uuid', 'date', 'datetime', 'timestamp',
159
+ 'json', 'enum', 'array', 'object', 'binary',
160
+ ])
161
+
162
+ function stableStringify(value: unknown): string {
163
+ if (value === null || typeof value !== 'object') return JSON.stringify(value)
164
+ if (Array.isArray(value)) return '[' + value.map((item) => stableStringify(item)).join(',') + ']'
165
+ const record = value as Record<string, unknown>
166
+ return '{' + Object.keys(record).sort().map((key) => JSON.stringify(key) + ':' + stableStringify(record[key])).join(',') + '}'
167
+ }
168
+
169
+ export function dataModelHash(model: DataModelV1): string {
170
+ return createHash('sha256').update(stableStringify(model)).digest('hex')
171
+ }
172
+
173
+ function bad(errors: string[], path: string, message: string): void {
174
+ errors.push(path + ': ' + message)
175
+ }
176
+
177
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
178
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
179
+ }
180
+
181
+ /**
182
+ * The declared keys of every object in the data model.
183
+ *
184
+ * The schema is closed for the reason the verification engine gives: an undeclared key is not a
185
+ * missing feature, it is a different model. `primryKey: ['id']` reads as "no primary key
186
+ * declared", and the check that would have complained reports a pass instead - a silent pass
187
+ * produced by a typo.
188
+ */
189
+ const DATA_MODEL_KEYS = {
190
+ model: new Set(['schemaVersion', 'entities', 'relationships', 'invariants', 'boundaryChecks']),
191
+ entity: new Set(['name', 'fields', 'primaryKey', 'uniqueKeys', 'indexes']),
192
+ field: new Set(['name', 'type', 'required', 'unique', 'nullable', 'default', 'min', 'max', 'minLength', 'maxLength', 'pattern', 'enum', 'ref', 'items', 'monotonic']),
193
+ invariant: new Set(['id', 'description', 'kind', 'entity', 'field', 'fields', 'refEntity', 'refField', 'sourceEntity', 'sourceField', 'targetEntity', 'targetField', 'min', 'max', 'direction', 'steps', 'from', 'to', 'commit', 'rollback']),
194
+ boundaryCheck: new Set(['entity', 'field', 'values']),
195
+ }
196
+
197
+ /** Refuse an undeclared key. Underscore-prefixed keys are metadata and are ignored. */
198
+ function rejectUnknownKeys(value: Record<string, unknown>, allowed: Set<string>, path: string, errors: string[]): void {
199
+ for (const key of Object.keys(value)) {
200
+ if (key.startsWith('_')) continue
201
+ if (allowed.has(key)) continue
202
+ bad(errors, path + '.' + key, 'unknown field (the schema is closed; a typo would silently change the model under review)')
203
+ }
204
+ }
205
+
206
+ function validateField(input: unknown, path: string, errors: string[], fieldNames: Set<string>): FieldSpec | undefined {
207
+ if (!isPlainObject(input)) {
208
+ bad(errors, path, 'must be an object')
209
+ return undefined
210
+ }
211
+ rejectUnknownKeys(input, DATA_MODEL_KEYS.field, path, errors)
212
+ if (typeof input.name !== 'string' || input.name.length === 0) {
213
+ bad(errors, path + '.name', 'must be a non-empty string')
214
+ } else if (fieldNames.has(input.name)) {
215
+ bad(errors, path + '.name', 'duplicate field name ' + input.name)
216
+ } else {
217
+ fieldNames.add(input.name)
218
+ }
219
+ if (typeof input.type !== 'string' || input.type.length === 0) {
220
+ bad(errors, path + '.type', 'must be a non-empty string')
221
+ }
222
+ if (input.required !== undefined && typeof input.required !== 'boolean') bad(errors, path + '.required', 'must be a boolean')
223
+ if (input.unique !== undefined && typeof input.unique !== 'boolean') bad(errors, path + '.unique', 'must be a boolean')
224
+ if (input.nullable !== undefined && typeof input.nullable !== 'boolean') bad(errors, path + '.nullable', 'must be a boolean')
225
+ if (input.min !== undefined && typeof input.min !== 'number') bad(errors, path + '.min', 'must be a number')
226
+ if (input.max !== undefined && typeof input.max !== 'number') bad(errors, path + '.max', 'must be a number')
227
+ if (input.minLength !== undefined && typeof input.minLength !== 'number') bad(errors, path + '.minLength', 'must be a number')
228
+ if (input.maxLength !== undefined && typeof input.maxLength !== 'number') bad(errors, path + '.maxLength', 'must be a number')
229
+ if (input.monotonic !== undefined && input.monotonic !== 'inc' && input.monotonic !== 'dec') bad(errors, path + '.monotonic', "must be 'inc' or 'dec'")
230
+ if (input.pattern !== undefined && typeof input.pattern !== 'string') bad(errors, path + '.pattern', 'must be a string')
231
+ if (input.ref !== undefined && typeof input.ref !== 'string') bad(errors, path + '.ref', 'must be a string')
232
+ if (input.enum !== undefined && !Array.isArray(input.enum)) bad(errors, path + '.enum', 'must be an array')
233
+ if (input.items !== undefined) validateField(input.items, path + '.items', errors, new Set<string>())
234
+ return input as unknown as FieldSpec
235
+ }
236
+
237
+ function entityByName(model: DataModelV1, name: string): EntitySpec | undefined {
238
+ return model.entities.find((entity) => entity.name === name)
239
+ }
240
+
241
+ function fieldByPath(model: DataModelV1, entityName: string, fieldName: string): FieldSpec | undefined {
242
+ return entityByName(model, entityName)?.fields.find((field) => field.name === fieldName)
243
+ }
244
+
245
+ function mapFieldPath(mapping: Record<string, string>, path: string): string {
246
+ return mapping[path] ?? path
247
+ }
248
+
249
+ export function validateDataModel(input: unknown): { ok: true; model: DataModelV1 } | { ok: false; errors: string[] } {
250
+ const errors: string[] = []
251
+ if (!isPlainObject(input)) {
252
+ return { ok: false, errors: ['model: must be an object'] }
253
+ }
254
+ const root = input as Record<string, unknown>
255
+ rejectUnknownKeys(root, DATA_MODEL_KEYS.model, 'model', errors)
256
+ if (root.schemaVersion !== 1) bad(errors, 'schemaVersion', 'must be 1')
257
+ if (!Array.isArray(root.entities) || root.entities.length === 0) {
258
+ bad(errors, 'entities', 'must be a non-empty array')
259
+ } else {
260
+ const entityNames = new Set<string>()
261
+ root.entities.forEach((entry, index) => {
262
+ const path = 'entities[' + index + ']'
263
+ if (!isPlainObject(entry)) {
264
+ bad(errors, path, 'must be an object')
265
+ return
266
+ }
267
+ rejectUnknownKeys(entry, DATA_MODEL_KEYS.entity, path, errors)
268
+ if (typeof entry.name !== 'string' || entry.name.length === 0) {
269
+ bad(errors, path + '.name', 'must be a non-empty string')
270
+ } else if (entityNames.has(entry.name)) {
271
+ bad(errors, path + '.name', 'duplicate entity name ' + entry.name)
272
+ } else {
273
+ entityNames.add(entry.name)
274
+ }
275
+ if (!Array.isArray(entry.fields) || entry.fields.length === 0) {
276
+ bad(errors, path + '.fields', 'must be a non-empty array')
277
+ } else {
278
+ const fieldNames = new Set<string>()
279
+ entry.fields.forEach((field, fieldIndex) => {
280
+ validateField(field, path + '.fields[' + fieldIndex + ']', errors, fieldNames)
281
+ })
282
+ }
283
+ if (entry.primaryKey !== undefined) {
284
+ if (!Array.isArray(entry.primaryKey)) bad(errors, path + '.primaryKey', 'must be an array')
285
+ }
286
+ if (entry.uniqueKeys !== undefined) {
287
+ if (!Array.isArray(entry.uniqueKeys)) bad(errors, path + '.uniqueKeys', 'must be an array')
288
+ }
289
+ if (entry.indexes !== undefined) {
290
+ if (!Array.isArray(entry.indexes)) bad(errors, path + '.indexes', 'must be an array')
291
+ }
292
+ })
293
+ }
294
+ if (root.relationships !== undefined) {
295
+ if (!Array.isArray(root.relationships)) bad(errors, 'relationships', 'must be an array')
296
+ }
297
+ if (root.invariants !== undefined) {
298
+ if (!Array.isArray(root.invariants)) bad(errors, 'invariants', 'must be an array')
299
+ else root.invariants.forEach((entry, index) => {
300
+ const path = 'invariants[' + index + ']'
301
+ if (!isPlainObject(entry)) {
302
+ bad(errors, path, 'must be an object')
303
+ return
304
+ }
305
+ rejectUnknownKeys(entry, DATA_MODEL_KEYS.invariant, path, errors)
306
+ if (typeof entry.id !== 'string' || entry.id.length === 0) bad(errors, path + '.id', 'must be a non-empty string')
307
+ if (typeof entry.description !== 'string') bad(errors, path + '.description', 'must be a string')
308
+ const kind = entry.kind
309
+ if (kind === 'field-required' || kind === 'no-orphan') {
310
+ if (typeof entry.entity !== 'string') bad(errors, path + '.entity', 'must be a string')
311
+ if (typeof entry.field !== 'string') bad(errors, path + '.field', 'must be a string')
312
+ } else if (kind === 'unique') {
313
+ if (!Array.isArray(entry.fields)) bad(errors, path + '.fields', 'must be an array')
314
+ } else if (kind === 'referential-integrity') {
315
+ if (typeof entry.entity !== 'string') bad(errors, path + '.entity', 'must be a string')
316
+ if (typeof entry.field !== 'string') bad(errors, path + '.field', 'must be a string')
317
+ if (typeof entry.refEntity !== 'string') bad(errors, path + '.refEntity', 'must be a string')
318
+ if (entry.refField !== undefined && typeof entry.refField !== 'string') bad(errors, path + '.refField', 'must be a string')
319
+ } else if (kind === 'range') {
320
+ if (typeof entry.entity !== 'string') bad(errors, path + '.entity', 'must be a string')
321
+ if (typeof entry.field !== 'string') bad(errors, path + '.field', 'must be a string')
322
+ if (entry.min !== undefined && typeof entry.min !== 'number') bad(errors, path + '.min', 'must be a number')
323
+ if (entry.max !== undefined && typeof entry.max !== 'number') bad(errors, path + '.max', 'must be a number')
324
+ } else if (kind === 'count-equal') {
325
+ if (typeof entry.sourceEntity !== 'string') bad(errors, path + '.sourceEntity', 'must be a string')
326
+ if (typeof entry.targetEntity !== 'string') bad(errors, path + '.targetEntity', 'must be a string')
327
+ } else if (kind === 'field-equal') {
328
+ if (typeof entry.sourceEntity !== 'string') bad(errors, path + '.sourceEntity', 'must be a string')
329
+ if (typeof entry.sourceField !== 'string') bad(errors, path + '.sourceField', 'must be a string')
330
+ if (typeof entry.targetEntity !== 'string') bad(errors, path + '.targetEntity', 'must be a string')
331
+ if (typeof entry.targetField !== 'string') bad(errors, path + '.targetField', 'must be a string')
332
+ } else if (kind === 'idempotent-copy') {
333
+ if (typeof entry.sourceEntity !== 'string') bad(errors, path + '.sourceEntity', 'must be a string')
334
+ if (typeof entry.targetEntity !== 'string') bad(errors, path + '.targetEntity', 'must be a string')
335
+ } else if (kind === 'idempotent-migration') {
336
+ if (typeof entry.from !== 'string') bad(errors, path + '.from', 'must be a string')
337
+ if (typeof entry.to !== 'string') bad(errors, path + '.to', 'must be a string')
338
+ } else if (kind === 'monotonic') {
339
+ if (typeof entry.entity !== 'string') bad(errors, path + '.entity', 'must be a string')
340
+ if (typeof entry.field !== 'string') bad(errors, path + '.field', 'must be a string')
341
+ if (entry.direction !== 'inc' && entry.direction !== 'dec') bad(errors, path + '.direction', "must be 'inc' or 'dec'")
342
+ } else if (kind === 'sequence') {
343
+ if (!Array.isArray(entry.steps) || entry.steps.length === 0) bad(errors, path + '.steps', 'must be a non-empty array')
344
+ else entry.steps.forEach((step, stepIndex) => {
345
+ if (typeof step !== 'string' || step.length === 0) bad(errors, path + '.steps[' + stepIndex + ']', 'must be a non-empty string')
346
+ })
347
+ } else if (kind === 'leads-to') {
348
+ if (typeof entry.entity !== 'string') bad(errors, path + '.entity', 'must be a string')
349
+ if (typeof entry.field !== 'string') bad(errors, path + '.field', 'must be a string')
350
+ } else if (kind === 'atomicity') {
351
+ if (!Array.isArray(entry.steps) || entry.steps.length === 0) bad(errors, path + '.steps', 'must be a non-empty array')
352
+ else entry.steps.forEach((step, stepIndex) => {
353
+ if (typeof step !== 'string' || step.length === 0) bad(errors, path + '.steps[' + stepIndex + ']', 'must be a non-empty string')
354
+ })
355
+ } else {
356
+ bad(errors, path + '.kind', 'unknown invariant kind')
357
+ }
358
+ })
359
+ }
360
+ if (root.boundaryChecks !== undefined) {
361
+ if (!Array.isArray(root.boundaryChecks)) bad(errors, 'boundaryChecks', 'must be an array')
362
+ else root.boundaryChecks.forEach((entry, index) => {
363
+ if (isPlainObject(entry)) rejectUnknownKeys(entry, DATA_MODEL_KEYS.boundaryCheck, 'boundaryChecks[' + String(index) + ']', errors)
364
+ })
365
+ }
366
+ if (errors.length > 0) return { ok: false, errors }
367
+ return { ok: true, model: root as unknown as DataModelV1 }
368
+ }
369
+
370
+ /**
371
+ * What a check would have needed in order to examine anything, or `undefined` if it had it.
372
+ *
373
+ * This family reported `pass` for checks whose vocabulary the model never declared - `DS3` with no
374
+ * relationships, `DA6` with no copy pairs, `DA5` with no before model, whose own detail read
375
+ * "No beforeModel supplied — skipped". The design family has always reported `skip` for that, and the
376
+ * verification family now does too, so the plugin has one meaning for the status rather than three.
377
+ *
378
+ * `DS1`, `DS2`, `DS4` and `DA1` are absent on purpose: they walk entities and fields, which every valid
379
+ * model has, so they always have something to look at.
380
+ */
381
+ function nothingToExamine(id: string, model: DataModelV1, options: DataVerificationOptions): string | undefined {
382
+ const invariantsOfKind = (kind: string) => (model.invariants ?? []).filter((invariant) => invariant.kind === kind).length
383
+ switch (id) {
384
+ case 'DS3':
385
+ case 'DA4':
386
+ return (model.relationships ?? []).length === 0 ? 'no relationships were declared' : undefined
387
+ case 'DS5':
388
+ return (model.invariants ?? []).length === 0 ? 'no invariants were declared' : undefined
389
+ case 'DA2':
390
+ return (model.boundaryChecks ?? []).length === 0 ? 'no boundary checks were declared' : undefined
391
+ case 'DA3': {
392
+ const hasKeys = model.entities.some((entity) => (entity.primaryKey ?? []).length > 0 || (entity.uniqueKeys ?? []).length > 0)
393
+ return hasKeys ? undefined : 'no primary key or unique constraint was declared'
394
+ }
395
+ case 'DA5':
396
+ return options.beforeModel === undefined ? 'no beforeModel was supplied' : undefined
397
+ case 'DA6':
398
+ case 'DA7':
399
+ return (options.copyPairs ?? []).length === 0 ? 'no copy pairs were declared' : undefined
400
+ case 'DA8':
401
+ return (model.invariants ?? []).length === 0 ? 'no invariants were declared' : undefined
402
+ case 'DA9':
403
+ return invariantsOfKind('monotonic') === 0 ? 'no monotonic invariant was declared' : undefined
404
+ case 'DA10':
405
+ return invariantsOfKind('sequence') === 0 ? 'no sequence invariant was declared' : undefined
406
+ case 'DA11':
407
+ return invariantsOfKind('leads-to') === 0 ? 'no leads-to invariant was declared' : undefined
408
+ case 'DA12':
409
+ return invariantsOfKind('atomicity') === 0 ? 'no atomicity invariant was declared' : undefined
410
+ default:
411
+ return undefined
412
+ }
413
+ }
414
+
415
+ function checkResult(id: string, name: string, findings: Finding[], detail: string): CheckResult {
416
+ const errors = findings.filter((finding) => finding.severity === 'error').length
417
+ const warnings = findings.filter((finding) => finding.severity === 'warning').length
418
+ return {
419
+ id,
420
+ name,
421
+ status: findings.length === 0 ? 'pass' : 'fail',
422
+ detail: detail + (errors > 0 ? ' (' + errors + ' errors' + (warnings > 0 ? ', ' + warnings + ' warnings' : '') + ')' : warnings > 0 ? ' (' + warnings + ' warnings)' : ''),
423
+ findings,
424
+ }
425
+ }
426
+
427
+ function allFieldPaths(model: DataModelV1): string[] {
428
+ const result: string[] = []
429
+ for (const entity of model.entities) {
430
+ for (const field of entity.fields) result.push(entity.name + '.' + field.name)
431
+ }
432
+ return result
433
+ }
434
+
435
+ function entityFieldMap(model: DataModelV1): Map<string, FieldSpec> {
436
+ const map = new Map<string, FieldSpec>()
437
+ for (const entity of model.entities) {
438
+ for (const field of entity.fields) map.set(entity.name + '.' + field.name, field)
439
+ }
440
+ return map
441
+ }
442
+
443
+ function DS1_schemaWellFormed(model: DataModelV1): CheckResult {
444
+ const findings: Finding[] = []
445
+ const entityNames = new Set<string>()
446
+ for (const entity of model.entities) {
447
+ if (entityNames.has(entity.name)) {
448
+ findings.push({ code: 'DS1_DUPLICATE_ENTITY', severity: 'error', message: 'Duplicate entity name ' + entity.name, evidence: { entity: entity.name } })
449
+ }
450
+ entityNames.add(entity.name)
451
+ const fieldNames = new Set<string>()
452
+ for (const field of entity.fields) {
453
+ if (fieldNames.has(field.name)) {
454
+ findings.push({ code: 'DS1_DUPLICATE_FIELD', severity: 'error', message: 'Duplicate field ' + entity.name + '.' + field.name, evidence: { entity: entity.name, field: field.name } })
455
+ }
456
+ fieldNames.add(field.name)
457
+ if (field.type !== undefined && !KNOWN_TYPES.has(field.type) && field.type.trim().length === 0) {
458
+ findings.push({ code: 'DS1_EMPTY_TYPE', severity: 'error', message: 'Field ' + entity.name + '.' + field.name + ' has empty type', evidence: { entity: entity.name, field: field.name } })
459
+ }
460
+ }
461
+ for (const key of entity.primaryKey ?? []) {
462
+ if (!fieldNames.has(key)) findings.push({ code: 'DS1_PRIMARY_KEY_MISSING', severity: 'error', message: 'Primary key field ' + key + ' does not exist in entity ' + entity.name, evidence: { entity: entity.name, field: key } })
463
+ }
464
+ for (const keySet of entity.uniqueKeys ?? []) {
465
+ for (const key of keySet) {
466
+ if (!fieldNames.has(key)) findings.push({ code: 'DS1_UNIQUE_KEY_MISSING', severity: 'error', message: 'Unique key field ' + key + ' does not exist in entity ' + entity.name, evidence: { entity: entity.name, field: key } })
467
+ }
468
+ }
469
+ for (const keySet of entity.indexes ?? []) {
470
+ for (const key of keySet) {
471
+ if (!fieldNames.has(key)) findings.push({ code: 'DS1_INDEX_MISSING', severity: 'error', message: 'Index field ' + key + ' does not exist in entity ' + entity.name, evidence: { entity: entity.name, field: key } })
472
+ }
473
+ }
474
+ }
475
+ return checkResult('DS1', 'Schema Well-Formedness', findings, findings.length === 0 ? 'Schema is well-formed' : 'Schema findings: ' + findings.length)
476
+ }
477
+
478
+ function DS2_requiredCompleteness(model: DataModelV1, options: DataVerificationOptions): CheckResult {
479
+ const findings: Finding[] = []
480
+ const afterFields = entityFieldMap(model)
481
+ const migrationFrom = new Set((options.migrationMappings ?? []).map((mapping) => mapping.from))
482
+ const copyTargets = new Set<string>()
483
+ for (const pair of options.copyPairs ?? []) {
484
+ for (const target of Object.values(pair.mapping)) copyTargets.add(pair.targetEntity + '.' + target)
485
+ }
486
+ const beforePaths = new Set<string>()
487
+ if (options.beforeModel !== undefined) {
488
+ const beforeValidation = validateDataModel(options.beforeModel)
489
+ if (beforeValidation.ok) {
490
+ const mapping = options.fieldMapping ?? {}
491
+ for (const path of allFieldPaths(beforeValidation.model)) beforePaths.add(mapFieldPath(mapping, path))
492
+ }
493
+ }
494
+ for (const entity of model.entities) {
495
+ for (const field of entity.fields) {
496
+ if (field.required !== true) continue
497
+ if ((entity.primaryKey ?? []).includes(field.name) && field.nullable === true) {
498
+ findings.push({ code: 'DS2_NULLABLE_PRIMARY_KEY', severity: 'error', message: 'Primary key field ' + entity.name + '.' + field.name + ' must not be nullable', evidence: { entity: entity.name, field: field.name } })
499
+ }
500
+ if (options.beforeModel !== undefined) {
501
+ const path = entity.name + '.' + field.name
502
+ if (!beforePaths.has(path) && !migrationFrom.has(path) && !copyTargets.has(path)) {
503
+ findings.push({ code: 'DS2_REQUIRED_FIELD_UNSOURCED', severity: 'warning', message: 'Required field ' + path + ' has no explicit migration mapping or copy target in a before/after migration', evidence: { entity: entity.name, field: field.name } })
504
+ }
505
+ }
506
+ }
507
+ }
508
+ return checkResult('DS2', 'Required-Field Completeness', findings, findings.length === 0 ? 'Required fields are complete' : 'Required-field findings: ' + findings.length)
509
+ }
510
+
511
+ function DS3_relationshipIntegrity(model: DataModelV1): CheckResult {
512
+ const findings: Finding[] = []
513
+ for (const relationship of model.relationships ?? []) {
514
+ const fromEntity = entityByName(model, relationship.fromEntity)
515
+ const toEntity = entityByName(model, relationship.toEntity)
516
+ if (fromEntity === undefined) {
517
+ findings.push({ code: 'DS3_FROM_ENTITY_MISSING', severity: 'error', message: 'Relationship references unknown fromEntity ' + relationship.fromEntity, evidence: { relationship } })
518
+ continue
519
+ }
520
+ if (toEntity === undefined) {
521
+ findings.push({ code: 'DS3_TO_ENTITY_MISSING', severity: 'error', message: 'Relationship references unknown toEntity ' + relationship.toEntity, evidence: { relationship } })
522
+ continue
523
+ }
524
+ if (!fromEntity.fields.some((field) => field.name === relationship.fromField)) {
525
+ findings.push({ code: 'DS3_FROM_FIELD_MISSING', severity: 'error', message: 'Relationship field ' + relationship.fromField + ' does not exist in ' + relationship.fromEntity, evidence: { relationship } })
526
+ }
527
+ if (relationship.toField !== undefined && !toEntity.fields.some((field) => field.name === relationship.toField)) {
528
+ findings.push({ code: 'DS3_TO_FIELD_MISSING', severity: 'error', message: 'Relationship target field ' + relationship.toField + ' does not exist in ' + relationship.toEntity, evidence: { relationship } })
529
+ }
530
+ if (relationship.onDelete !== undefined && !['cascade', 'restrict', 'set-null', 'no-action'].includes(relationship.onDelete)) {
531
+ findings.push({ code: 'DS3_INVALID_ON_DELETE', severity: 'error', message: 'Invalid onDelete value ' + relationship.onDelete, evidence: { relationship } })
532
+ }
533
+ }
534
+ return checkResult('DS3', 'Relationship Integrity', findings, findings.length === 0 ? 'Relationships are valid' : 'Relationship findings: ' + findings.length)
535
+ }
536
+
537
+ function DS4_typeNullabilityConsistency(model: DataModelV1): CheckResult {
538
+ const findings: Finding[] = []
539
+ for (const entity of model.entities) {
540
+ for (const field of entity.fields) {
541
+ if (field.required === true && field.nullable === true) {
542
+ findings.push({ code: 'DS4_REQUIRED_NULLABLE', severity: 'error', message: 'Field ' + entity.name + '.' + field.name + ' is both required and nullable', evidence: { entity: entity.name, field: field.name } })
543
+ }
544
+ if (field.unique === true && field.nullable === true) {
545
+ findings.push({ code: 'DS4_UNIQUE_NULLABLE', severity: 'warning', message: 'Unique field ' + entity.name + '.' + field.name + ' is nullable; uniqueness over NULL may be ambiguous', evidence: { entity: entity.name, field: field.name } })
546
+ }
547
+ if (typeof field.min === 'number' && typeof field.max === 'number' && field.min > field.max) {
548
+ findings.push({ code: 'DS4_MIN_GT_MAX', severity: 'error', message: 'Field ' + entity.name + '.' + field.name + ' has min > max', evidence: { entity: entity.name, field: field.name, min: field.min, max: field.max } })
549
+ }
550
+ if (typeof field.minLength === 'number' && typeof field.maxLength === 'number' && field.minLength > field.maxLength) {
551
+ findings.push({ code: 'DS4_MIN_LENGTH_GT_MAX_LENGTH', severity: 'error', message: 'Field ' + entity.name + '.' + field.name + ' has minLength > maxLength', evidence: { entity: entity.name, field: field.name } })
552
+ }
553
+ if (field.default === null && field.required === true) {
554
+ findings.push({ code: 'DS4_REQUIRED_NULL_DEFAULT', severity: 'warning', message: 'Required field ' + entity.name + '.' + field.name + ' has default null', evidence: { entity: entity.name, field: field.name } })
555
+ }
556
+ }
557
+ }
558
+ return checkResult('DS4', 'Type/Nullability Consistency', findings, findings.length === 0 ? 'Types and nullability are consistent' : 'Type/nullability findings: ' + findings.length)
559
+ }
560
+
561
+ /**
562
+ * DS5: the entity and field an invariant names must exist.
563
+ *
564
+ * Seven of the thirteen kinds - field-required, no-orphan, unique, referential-integrity, range,
565
+ * count-equal and field-equal - accepted any string as a name and never looked it up, so an
566
+ * invariant about a field that does not exist produced no finding at all and the report could say
567
+ * the model satisfied a claim it had never read.
568
+ *
569
+ * This is the same defect `DA2_BOUNDARY_FIELD_MISSING` catches one check over, and it gets its own
570
+ * check so a reader can see which part of the model failed. The kinds that already resolve their
571
+ * references keep their own codes: DA9 for monotonic, DA10 for sequence steps, DA11 for leads-to,
572
+ * DS3 for relationships, DS4 for key fields.
573
+ */
574
+ function DS5_invariantReferences(model: DataModelV1): CheckResult {
575
+ const findings: Finding[] = []
576
+ const entityNames = new Set(model.entities.map((entity) => entity.name))
577
+ const entityMissing = (invariant: { id: string }, name: unknown, role: string): void => {
578
+ if (typeof name !== 'string' || entityNames.has(name)) return
579
+ findings.push({
580
+ code: 'DS5_INVARIANT_ENTITY_MISSING',
581
+ severity: 'error',
582
+ message: 'Invariant "' + invariant.id + '" names ' + role + ' entity ' + JSON.stringify(name) + ', which the model does not declare, so the invariant was never read.',
583
+ detail: 'A reference that resolves to nothing leaves the check reporting a result for a claim it did not evaluate.',
584
+ evidence: { invariant, entity: name, role },
585
+ })
586
+ }
587
+ const fieldMissing = (invariant: { id: string }, entity: unknown, field: unknown, role: string): void => {
588
+ if (typeof entity !== 'string' || typeof field !== 'string') return
589
+ const declared = model.entities.find((candidate) => candidate.name === entity)
590
+ // An entity that does not exist is already reported; do not report its fields twice.
591
+ if (declared === undefined) return
592
+ if (declared.fields.some((candidate) => candidate.name === field)) return
593
+ findings.push({
594
+ code: 'DS5_INVARIANT_FIELD_MISSING',
595
+ severity: 'error',
596
+ message: 'Invariant "' + invariant.id + '" names ' + role + ' field ' + JSON.stringify(entity + '.' + field) + ', which the entity does not declare, so the invariant was never read.',
597
+ detail: 'A reference that resolves to nothing leaves the check reporting a result for a claim it did not evaluate.',
598
+ evidence: { invariant, entity, field, role },
599
+ })
600
+ }
601
+ for (const invariant of model.invariants ?? []) {
602
+ if (invariant.kind === 'field-required' || invariant.kind === 'no-orphan' || invariant.kind === 'range') {
603
+ entityMissing(invariant, invariant.entity, 'its')
604
+ fieldMissing(invariant, invariant.entity, invariant.field, 'its')
605
+ } else if (invariant.kind === 'unique') {
606
+ entityMissing(invariant, invariant.entity, 'its')
607
+ for (const field of invariant.fields) fieldMissing(invariant, invariant.entity, field, 'a')
608
+ } else if (invariant.kind === 'referential-integrity') {
609
+ entityMissing(invariant, invariant.entity, 'its')
610
+ fieldMissing(invariant, invariant.entity, invariant.field, 'its')
611
+ entityMissing(invariant, invariant.refEntity, 'the referenced')
612
+ if (invariant.refField !== undefined) fieldMissing(invariant, invariant.refEntity, invariant.refField, 'the referenced')
613
+ } else if (invariant.kind === 'count-equal') {
614
+ entityMissing(invariant, invariant.sourceEntity, 'the source')
615
+ entityMissing(invariant, invariant.targetEntity, 'the target')
616
+ } else if (invariant.kind === 'field-equal') {
617
+ entityMissing(invariant, invariant.sourceEntity, 'the source')
618
+ fieldMissing(invariant, invariant.sourceEntity, invariant.sourceField, 'the source')
619
+ entityMissing(invariant, invariant.targetEntity, 'the target')
620
+ fieldMissing(invariant, invariant.targetEntity, invariant.targetField, 'the target')
621
+ }
622
+ }
623
+ return checkResult('DS5', 'Invariant References', findings, findings.length === 0 ? 'every invariant names a declared entity and field' : 'Unresolved invariant references: ' + findings.length)
624
+ }
625
+
626
+ function DA1_nullEmptyInjection(model: DataModelV1): CheckResult {
627
+ const findings: Finding[] = []
628
+ for (const entity of model.entities) {
629
+ for (const field of entity.fields) {
630
+ if (field.required === true && field.nullable === true) {
631
+ findings.push({ code: 'DA1_NULL_ACCEPTED_ON_REQUIRED', severity: 'error', message: 'Null can be injected into required field ' + entity.name + '.' + field.name, evidence: { entity: entity.name, field: field.name } })
632
+ }
633
+ if (field.required === true && field.type === 'string' && field.minLength !== undefined && field.minLength === 0) {
634
+ findings.push({ code: 'DA1_EMPTY_STRING_ALLOWED', severity: 'warning', message: 'Required string field ' + entity.name + '.' + field.name + ' allows empty string', evidence: { entity: entity.name, field: field.name } })
635
+ }
636
+ }
637
+ }
638
+ return checkResult('DA1', 'Null/Empty Injection', findings, findings.length === 0 ? 'Null/empty injection is rejected' : 'Null/empty findings: ' + findings.length)
639
+ }
640
+
641
+ function DA2_boundaryBlast(model: DataModelV1): CheckResult {
642
+ const findings: Finding[] = []
643
+ for (const entity of model.entities) {
644
+ for (const field of entity.fields) {
645
+ if (typeof field.min === 'number' && typeof field.max === 'number' && field.min > field.max) continue
646
+ if (typeof field.default === 'number') {
647
+ if (field.min !== undefined && field.default < field.min) findings.push({ code: 'DA2_DEFAULT_BELOW_MIN', severity: 'error', message: 'Default ' + field.default + ' is below min ' + field.min + ' for ' + entity.name + '.' + field.name, evidence: { entity: entity.name, field: field.name } })
648
+ if (field.max !== undefined && field.default > field.max) findings.push({ code: 'DA2_DEFAULT_ABOVE_MAX', severity: 'error', message: 'Default ' + field.default + ' is above max ' + field.max + ' for ' + entity.name + '.' + field.name, evidence: { entity: entity.name, field: field.name } })
649
+ }
650
+ if (field.enum !== undefined && field.enum.length > 0) {
651
+ if (typeof field.min === 'number') {
652
+ for (const value of field.enum) {
653
+ if (typeof value === 'number' && value < field.min) findings.push({ code: 'DA2_ENUM_BELOW_MIN', severity: 'warning', message: 'Enum value ' + value + ' is below min ' + field.min + ' for ' + entity.name + '.' + field.name, evidence: { entity: entity.name, field: field.name, value } })
654
+ }
655
+ }
656
+ if (typeof field.max === 'number') {
657
+ for (const value of field.enum) {
658
+ if (typeof value === 'number' && value > field.max) findings.push({ code: 'DA2_ENUM_ABOVE_MAX', severity: 'warning', message: 'Enum value ' + value + ' is above max ' + field.max + ' for ' + entity.name + '.' + field.name, evidence: { entity: entity.name, field: field.name, value } })
659
+ }
660
+ }
661
+ }
662
+ }
663
+ }
664
+ for (const check of model.boundaryChecks ?? []) {
665
+ const field = fieldByPath(model, check.entity, check.field)
666
+ if (field === undefined) {
667
+ findings.push({ code: 'DA2_BOUNDARY_FIELD_MISSING', severity: 'error', message: 'Boundary check references unknown field ' + check.entity + '.' + check.field, evidence: { entity: check.entity, field: check.field } })
668
+ continue
669
+ }
670
+ for (const value of check.values ?? []) {
671
+ if (typeof value === 'number') {
672
+ if (field.min !== undefined && value < field.min) findings.push({ code: 'DA2_BOUNDARY_BELOW_MIN', severity: 'warning', message: 'Boundary value ' + value + ' below min ' + field.min + ' for ' + check.entity + '.' + check.field, evidence: { entity: check.entity, field: check.field, value } })
673
+ if (field.max !== undefined && value > field.max) findings.push({ code: 'DA2_BOUNDARY_ABOVE_MAX', severity: 'warning', message: 'Boundary value ' + value + ' above max ' + field.max + ' for ' + check.entity + '.' + check.field, evidence: { entity: check.entity, field: check.field, value } })
674
+ }
675
+ if (field.enum !== undefined && field.enum.length > 0 && !field.enum.includes(value)) {
676
+ findings.push({ code: 'DA2_BOUNDARY_NOT_IN_ENUM', severity: 'warning', message: 'Boundary value ' + String(value) + ' not in enum for ' + check.entity + '.' + check.field, evidence: { entity: check.entity, field: check.field, value } })
677
+ }
678
+ }
679
+ }
680
+ return checkResult('DA2', 'Boundary Blast', findings, findings.length === 0 ? 'Boundary checks passed' : 'Boundary findings: ' + findings.length)
681
+ }
682
+
683
+ function DA3_uniquenessViolation(model: DataModelV1): CheckResult {
684
+ const findings: Finding[] = []
685
+ for (const entity of model.entities) {
686
+ for (const key of entity.primaryKey ?? []) {
687
+ const field = fieldByPath(model, entity.name, key)
688
+ if (field?.nullable === true) findings.push({ code: 'DA3_NULLABLE_PRIMARY_KEY', severity: 'error', message: 'Primary key ' + entity.name + '.' + key + ' is nullable', evidence: { entity: entity.name, field: key } })
689
+ }
690
+ for (const keySet of entity.uniqueKeys ?? []) {
691
+ for (const key of keySet) {
692
+ const field = fieldByPath(model, entity.name, key)
693
+ if (field?.nullable === true) findings.push({ code: 'DA3_NULLABLE_UNIQUE_KEY', severity: 'warning', message: 'Unique key ' + entity.name + '.' + key + ' is nullable', evidence: { entity: entity.name, field: key } })
694
+ }
695
+ }
696
+ }
697
+ return checkResult('DA3', 'Uniqueness Violation', findings, findings.length === 0 ? 'No uniqueness violations' : 'Uniqueness findings: ' + findings.length)
698
+ }
699
+
700
+ function DA4_referentialIntegrityViolation(model: DataModelV1): CheckResult {
701
+ const findings: Finding[] = []
702
+ for (const relationship of model.relationships ?? []) {
703
+ const fromField = fieldByPath(model, relationship.fromEntity, relationship.fromField)
704
+ const toField = relationship.toField === undefined ? undefined : fieldByPath(model, relationship.toEntity, relationship.toField)
705
+ if (fromField !== undefined && toField !== undefined && fromField.type !== toField.type) {
706
+ findings.push({ code: 'DA4_REF_TYPE_MISMATCH', severity: 'warning', message: 'Relationship ' + (relationship.name ?? relationship.fromEntity + '.' + relationship.fromField) + ' links different types ' + fromField.type + ' -> ' + toField.type, evidence: { relationship } })
707
+ }
708
+ }
709
+ return checkResult('DA4', 'Referential Integrity Violation', findings, findings.length === 0 ? 'No referential integrity violations' : 'Referential integrity findings: ' + findings.length)
710
+ }
711
+
712
+ function DA5_migrationCoverage(model: DataModelV1, options: DataVerificationOptions): CheckResult {
713
+ const findings: Finding[] = []
714
+ if (options.beforeModel === undefined) {
715
+ return checkResult('DA5', 'Migration Coverage', [], 'No beforeModel supplied — skipped')
716
+ }
717
+ const beforeValidation = validateDataModel(options.beforeModel)
718
+ if (!beforeValidation.ok) {
719
+ return checkResult('DA5', 'Migration Coverage', [{ code: 'DA5_BEFORE_MODEL_INVALID', severity: 'error', message: 'Before model failed validation: ' + beforeValidation.errors.join('; ') }], 'Before model invalid')
720
+ }
721
+ const before = beforeValidation.model
722
+ const afterPaths = new Set(allFieldPaths(model))
723
+ const beforePaths = allFieldPaths(before)
724
+ const mapping = options.fieldMapping ?? {}
725
+ const migrationFrom = new Set((options.migrationMappings ?? []).map((mapping) => mapping.from))
726
+ for (const path of beforePaths) {
727
+ const mapped = mapFieldPath(mapping, path)
728
+ if (!afterPaths.has(mapped) && !migrationFrom.has(path)) {
729
+ findings.push({ code: 'DA5_FIELD_NOT_MIGRATED', severity: 'error', message: 'BEFORE field ' + path + ' is not present in AFTER and has no migration mapping', evidence: { beforeField: path, afterField: mapped } })
730
+ }
731
+ }
732
+ return checkResult('DA5', 'Migration Coverage', findings, findings.length === 0 ? 'All before/after fields are covered by mappings' : 'Migration coverage findings: ' + findings.length)
733
+ }
734
+
735
+ function DA6_copyConsistency(model: DataModelV1, options: DataVerificationOptions): CheckResult {
736
+ const findings: Finding[] = []
737
+ const afterFields = entityFieldMap(model)
738
+ for (const pair of options.copyPairs ?? []) {
739
+ const targetEntity = entityByName(model, pair.targetEntity)
740
+ if (targetEntity === undefined) {
741
+ findings.push({ code: 'DA6_TARGET_ENTITY_MISSING', severity: 'error', message: 'Copy pair ' + pair.id + ' references unknown target entity ' + pair.targetEntity, evidence: { pair } })
742
+ continue
743
+ }
744
+ if (entityByName(model, pair.sourceEntity) === undefined) {
745
+ findings.push({ code: 'DA6_SOURCE_ENTITY_MISSING', severity: 'error', message: 'Copy pair ' + pair.id + ' references unknown source entity ' + pair.sourceEntity, evidence: { pair } })
746
+ }
747
+ const mappedTargets = new Set(Object.values(pair.mapping).map((field) => pair.targetEntity + '.' + field))
748
+ for (const field of targetEntity.fields) {
749
+ if (field.required === true && !mappedTargets.has(pair.targetEntity + '.' + field.name)) {
750
+ findings.push({ code: 'DA6_REQUIRED_TARGET_UNMAPPED', severity: 'error', message: 'Copy pair ' + pair.id + ' does not map required target field ' + pair.targetEntity + '.' + field.name, evidence: { pair, field: field.name } })
751
+ }
752
+ }
753
+ for (const [sourceField, targetField] of Object.entries(pair.mapping)) {
754
+ if (fieldByPath(model, pair.sourceEntity, sourceField) === undefined) {
755
+ findings.push({ code: 'DA6_SOURCE_FIELD_MISSING', severity: 'error', message: 'Copy pair ' + pair.id + ' maps unknown source field ' + pair.sourceEntity + '.' + sourceField, evidence: { pair, field: sourceField } })
756
+ }
757
+ if (afterFields.has(pair.targetEntity + '.' + targetField) === false) {
758
+ findings.push({ code: 'DA6_TARGET_FIELD_MISSING', severity: 'error', message: 'Copy pair ' + pair.id + ' maps unknown target field ' + pair.targetEntity + '.' + targetField, evidence: { pair, field: targetField } })
759
+ }
760
+ }
761
+ }
762
+ return checkResult('DA6', 'Copy Consistency', findings, findings.length === 0 ? 'Copy mappings are consistent' : 'Copy consistency findings: ' + findings.length)
763
+ }
764
+
765
+ function DA7_rollbackBackupSymmetry(model: DataModelV1, options: DataVerificationOptions): CheckResult {
766
+ const findings: Finding[] = []
767
+ const backupKeys = new Set((options.backupPairs ?? []).map((pair) => pair.sourceEntity + '|' + pair.targetEntity))
768
+ for (const pair of options.copyPairs ?? []) {
769
+ if (!backupKeys.has(pair.sourceEntity + '|' + pair.targetEntity)) {
770
+ findings.push({ code: 'DA7_NO_BACKUP_PAIR', severity: 'warning', message: 'Copy pair ' + pair.id + ' (' + pair.sourceEntity + ' -> ' + pair.targetEntity + ') has no matching backup/restore pair', evidence: { pair } })
771
+ }
772
+ }
773
+ return checkResult('DA7', 'Rollback/Backup Symmetry', findings, findings.length === 0 ? 'Copy/backup pairs are balanced' : 'Rollback/backup findings: ' + findings.length)
774
+ }
775
+
776
+ function DA8_idempotentConstraints(model: DataModelV1, options: DataVerificationOptions): CheckResult {
777
+ const findings: Finding[] = []
778
+ const copyPairs = options.copyPairs ?? []
779
+ const migrationMappings = options.migrationMappings ?? []
780
+ for (const invariant of model.invariants ?? []) {
781
+ if (invariant.kind === 'idempotent-copy') {
782
+ const pair = copyPairs.find((entry) => entry.sourceEntity === invariant.sourceEntity && entry.targetEntity === invariant.targetEntity)
783
+ if (pair === undefined) {
784
+ findings.push({
785
+ code: 'DA8_COPY_PAIR_MISSING',
786
+ severity: 'error',
787
+ message: 'Idempotent-copy invariant "' + invariant.id + '" has no matching copy pair for ' + invariant.sourceEntity + ' -> ' + invariant.targetEntity,
788
+ evidence: { invariant },
789
+ })
790
+ }
791
+ } else if (invariant.kind === 'idempotent-migration') {
792
+ const mapping = migrationMappings.find((entry) => entry.from === invariant.from && entry.to === invariant.to)
793
+ if (mapping === undefined) {
794
+ findings.push({
795
+ code: 'DA8_MIGRATION_MAPPING_MISSING',
796
+ severity: 'error',
797
+ message: 'Idempotent-migration invariant "' + invariant.id + '" has no matching migration mapping ' + invariant.from + ' -> ' + invariant.to,
798
+ evidence: { invariant },
799
+ })
800
+ } else if (mapping.transform === 'split' || mapping.transform === 'merge' || mapping.transform === 'drop') {
801
+ findings.push({
802
+ code: 'DA8_NON_IDEMPOTENT_TRANSFORM',
803
+ severity: 'warning',
804
+ message: 'Migration mapping ' + invariant.from + ' -> ' + invariant.to + ' uses non-idempotent transform ' + (mapping.transform ?? 'unknown'),
805
+ evidence: { invariant, mapping },
806
+ })
807
+ }
808
+ }
809
+ }
810
+ return checkResult('DA8', 'Idempotent Constraints', findings, findings.length === 0 ? 'Idempotency constraints are satisfied' : 'Idempotency findings: ' + findings.length)
811
+ }
812
+
813
+ function dataStepRegistry(options: DataVerificationOptions): Map<string, { kind: 'copy' | 'migration'; transform?: string; copyPair?: CopyPairSpec }> {
814
+ const map = new Map<string, { kind: 'copy' | 'migration'; transform?: string; copyPair?: CopyPairSpec }>()
815
+ for (const pair of options.copyPairs ?? []) map.set(pair.id, { kind: 'copy', copyPair: pair })
816
+ for (const mapping of options.migrationMappings ?? []) {
817
+ const id = mapping.id ?? mapping.from + '->' + mapping.to
818
+ map.set(id, { kind: 'migration', transform: mapping.transform })
819
+ }
820
+ return map
821
+ }
822
+
823
+ const MONOTONIC_DATA_TYPES = new Set<string>(['integer', 'number', 'date', 'datetime', 'timestamp'])
824
+
825
+ function DA9_dataMonotonic(model: DataModelV1, options: DataVerificationOptions): CheckResult {
826
+ const findings: Finding[] = []
827
+ for (const invariant of model.invariants ?? []) {
828
+ if (invariant.kind !== 'monotonic') continue
829
+ const field = fieldByPath(model, invariant.entity, invariant.field)
830
+ if (field === undefined) {
831
+ findings.push({ code: 'DA9_MONOTONIC_FIELD_MISSING', severity: 'error', message: 'Monotonic invariant "' + invariant.id + '" references missing field ' + invariant.entity + '.' + invariant.field, evidence: { invariant } })
832
+ continue
833
+ }
834
+ if (!MONOTONIC_DATA_TYPES.has(field.type)) {
835
+ findings.push({ code: 'DA9_MONOTONIC_TYPE_UNSUPPORTED', severity: 'warning', message: 'Monotonic field ' + invariant.entity + '.' + invariant.field + ' has type ' + field.type + ', which may not support monotonic ordering', evidence: { invariant } })
836
+ }
837
+ if (field.monotonic !== undefined && field.monotonic !== invariant.direction) {
838
+ findings.push({ code: 'DA9_MONOTONIC_DIRECTION_MISMATCH', severity: 'error', message: 'Monotonic invariant direction ' + invariant.direction + ' conflicts with field monotonic ' + field.monotonic + ' for ' + invariant.entity + '.' + invariant.field, evidence: { invariant } })
839
+ }
840
+ for (const pair of options.copyPairs ?? []) {
841
+ for (const [sourceField, targetField] of Object.entries(pair.mapping)) {
842
+ if (pair.targetEntity + '.' + targetField === invariant.entity + '.' + invariant.field) {
843
+ const source = fieldByPath(model, pair.sourceEntity, sourceField)
844
+ if (source?.monotonic !== undefined && source.monotonic !== invariant.direction) {
845
+ findings.push({ code: 'DA9_MONOTONIC_COPY_OPPOSITE', severity: 'warning', message: 'Copy ' + pair.id + ' maps opposite-monotonic source ' + pair.sourceEntity + '.' + sourceField + ' into monotonic target ' + invariant.entity + '.' + invariant.field, evidence: { invariant, pair } })
846
+ }
847
+ }
848
+ }
849
+ }
850
+ }
851
+ return checkResult('DA9', 'Data Monotonic', findings, findings.length === 0 ? 'Monotonic data constraints are satisfied' : 'Data monotonic findings: ' + findings.length)
852
+ }
853
+
854
+ function DA10_dataSequence(model: DataModelV1, options: DataVerificationOptions): CheckResult {
855
+ const findings: Finding[] = []
856
+ const registry = dataStepRegistry(options)
857
+ for (const invariant of model.invariants ?? []) {
858
+ if (invariant.kind !== 'sequence') continue
859
+ for (const step of invariant.steps) {
860
+ if (!registry.has(step)) {
861
+ findings.push({ code: 'DA10_SEQUENCE_STEP_MISSING', severity: 'error', message: 'Sequence invariant "' + invariant.id + '" references missing step ' + step, evidence: { invariant, step } })
862
+ }
863
+ }
864
+ }
865
+ return checkResult('DA10', 'Data Sequence', findings, findings.length === 0 ? 'Data sequence steps exist' : 'Data sequence findings: ' + findings.length)
866
+ }
867
+
868
+ function DA11_dataLeadsTo(model: DataModelV1, options: DataVerificationOptions): CheckResult {
869
+ const findings: Finding[] = []
870
+ for (const invariant of model.invariants ?? []) {
871
+ if (invariant.kind !== 'leads-to') continue
872
+ const field = fieldByPath(model, invariant.entity, invariant.field)
873
+ if (field === undefined) {
874
+ findings.push({ code: 'DA11_LEADS_TO_FIELD_MISSING', severity: 'error', message: 'Leads-to invariant "' + invariant.id + '" references missing field ' + invariant.entity + '.' + invariant.field, evidence: { invariant } })
875
+ continue
876
+ }
877
+ if (field.enum !== undefined && field.enum.length > 0) {
878
+ if (!field.enum.includes(invariant.from)) findings.push({ code: 'DA11_FROM_NOT_IN_ENUM', severity: 'warning', message: 'Leads-to from value ' + String(invariant.from) + ' is not in enum for ' + invariant.entity + '.' + invariant.field, evidence: { invariant } })
879
+ if (!field.enum.includes(invariant.to)) findings.push({ code: 'DA11_TO_NOT_IN_ENUM', severity: 'warning', message: 'Leads-to to value ' + String(invariant.to) + ' is not in enum for ' + invariant.entity + '.' + invariant.field, evidence: { invariant } })
880
+ }
881
+ }
882
+ return checkResult('DA11', 'Data Leads-To', findings, findings.length === 0 ? 'Data leads-to constraints are structurally valid' : 'Data leads-to findings: ' + findings.length)
883
+ }
884
+
885
+ function DA12_dataAtomicity(model: DataModelV1, options: DataVerificationOptions): CheckResult {
886
+ const findings: Finding[] = []
887
+ const registry = dataStepRegistry(options)
888
+ for (const invariant of model.invariants ?? []) {
889
+ if (invariant.kind !== 'atomicity') continue
890
+ for (const step of invariant.steps) {
891
+ const entry = registry.get(step)
892
+ if (entry === undefined) {
893
+ findings.push({ code: 'DA12_ATOMIC_STEP_MISSING', severity: 'error', message: 'Atomicity invariant "' + invariant.id + '" references missing step ' + step, evidence: { invariant, step } })
894
+ continue
895
+ }
896
+ if (entry.kind === 'migration' && (entry.transform === 'drop' || entry.transform === 'split' || entry.transform === 'merge')) {
897
+ findings.push({ code: 'DA12_NON_ATOMIC_TRANSFORM', severity: 'warning', message: 'Atomic step ' + step + ' uses non-atomic transform ' + entry.transform, evidence: { invariant, step } })
898
+ }
899
+ if (entry.kind === 'copy' && entry.copyPair !== undefined) {
900
+ const hasBackup = (options.backupPairs ?? []).some((pair) => pair.sourceEntity === entry.copyPair!.sourceEntity && pair.targetEntity === entry.copyPair!.targetEntity)
901
+ if (!hasBackup) findings.push({ code: 'DA12_ATOMIC_COPY_NO_BACKUP', severity: 'warning', message: 'Atomic copy step ' + step + ' has no backup/restore pair', evidence: { invariant, step } })
902
+ }
903
+ }
904
+ }
905
+ return checkResult('DA12', 'Data Atomicity', findings, findings.length === 0 ? 'Data atomicity constraints are satisfied' : 'Data atomicity findings: ' + findings.length)
906
+ }
907
+
908
+ function DD1_dataBehaviorPreservation(before: DataModelV1, after: DataModelV1, options: DataVerificationOptions): CheckResult {
909
+ const findings: Finding[] = []
910
+ const afterFields = entityFieldMap(after)
911
+ const mapping = options.fieldMapping ?? {}
912
+ const migrationFrom = new Set((options.migrationMappings ?? []).map((mapping) => mapping.from))
913
+ for (const entity of before.entities) {
914
+ for (const field of entity.fields) {
915
+ const path = entity.name + '.' + field.name
916
+ const mapped = mapFieldPath(mapping, path)
917
+ const afterField = afterFields.get(mapped)
918
+ if (afterField === undefined) {
919
+ if (!migrationFrom.has(path)) {
920
+ findings.push({ code: 'DD1_FIELD_REMOVED', severity: 'error', message: 'BEFORE field ' + path + ' is missing in AFTER and has no migration mapping', evidence: { beforeField: path, mapped } })
921
+ }
922
+ continue
923
+ }
924
+ if (field.required === true && afterField.required !== true && !migrationFrom.has(path)) {
925
+ findings.push({ code: 'DD1_REQUIRED_BECAME_OPTIONAL', severity: 'warning', message: 'BEFORE required field ' + path + ' is optional in AFTER', evidence: { beforeField: path, afterField: mapped } })
926
+ }
927
+ }
928
+ }
929
+ return checkResult('DD1', 'Data Behavior Preservation', findings, findings.length === 0 ? 'BEFORE data behavior is preserved in AFTER' : 'Data behavior findings: ' + findings.length)
930
+ }
931
+
932
+ function DD2_dataInvariantContinuity(before: DataModelV1, after: DataModelV1, options: DataVerificationOptions): CheckResult {
933
+ const findings: Finding[] = []
934
+ const afterEntities = new Set(after.entities.map((entity) => entity.name))
935
+ const afterFields = entityFieldMap(after)
936
+ const mapping = options.fieldMapping ?? {}
937
+ for (const invariant of before.invariants ?? []) {
938
+ if (invariant.kind === 'field-required' || invariant.kind === 'range' || invariant.kind === 'no-orphan') {
939
+ const mappedField = mapFieldPath(mapping, invariant.entity + '.' + invariant.field)
940
+ const [entityName, fieldName] = mappedField.split('.')
941
+ if (!afterEntities.has(entityName) || !afterFields.has(mappedField)) {
942
+ findings.push({ code: 'DD2_INVARIANT_FIELD_MISSING', severity: 'error', message: 'BEFORE invariant "' + invariant.id + '" references ' + mappedField + ', missing in AFTER', evidence: { invariant, mappedField } })
943
+ }
944
+ } else if (invariant.kind === 'unique') {
945
+ for (const field of invariant.fields) {
946
+ const mappedField = mapFieldPath(mapping, invariant.entity + '.' + field)
947
+ if (!afterFields.has(mappedField)) {
948
+ findings.push({ code: 'DD2_INVARIANT_FIELD_MISSING', severity: 'error', message: 'BEFORE unique invariant "' + invariant.id + '" references ' + mappedField + ', missing in AFTER', evidence: { invariant, mappedField } })
949
+ }
950
+ }
951
+ } else if (invariant.kind === 'referential-integrity') {
952
+ const mappedField = mapFieldPath(mapping, invariant.entity + '.' + invariant.field)
953
+ if (!afterFields.has(mappedField)) {
954
+ findings.push({ code: 'DD2_INVARIANT_FIELD_MISSING', severity: 'error', message: 'BEFORE referential invariant "' + invariant.id + '" references ' + mappedField + ', missing in AFTER', evidence: { invariant, mappedField } })
955
+ }
956
+ if (!afterEntities.has(invariant.refEntity)) {
957
+ findings.push({ code: 'DD2_INVARIANT_ENTITY_MISSING', severity: 'error', message: 'BEFORE referential invariant "' + invariant.id + '" references missing entity ' + invariant.refEntity, evidence: { invariant } })
958
+ }
959
+ } else if (invariant.kind === 'count-equal') {
960
+ if (!afterEntities.has(invariant.sourceEntity)) findings.push({ code: 'DD2_INVARIANT_ENTITY_MISSING', severity: 'error', message: 'BEFORE count-equal invariant "' + invariant.id + '" references missing source entity ' + invariant.sourceEntity, evidence: { invariant } })
961
+ if (!afterEntities.has(invariant.targetEntity)) findings.push({ code: 'DD2_INVARIANT_ENTITY_MISSING', severity: 'error', message: 'BEFORE count-equal invariant "' + invariant.id + '" references missing target entity ' + invariant.targetEntity, evidence: { invariant } })
962
+ } else if (invariant.kind === 'field-equal') {
963
+ const sourceMapped = mapFieldPath(mapping, invariant.sourceEntity + '.' + invariant.sourceField)
964
+ const targetMapped = mapFieldPath(mapping, invariant.targetEntity + '.' + invariant.targetField)
965
+ if (!afterFields.has(sourceMapped)) findings.push({ code: 'DD2_INVARIANT_FIELD_MISSING', severity: 'error', message: 'BEFORE field-equal invariant "' + invariant.id + '" references missing source field ' + sourceMapped, evidence: { invariant, sourceMapped } })
966
+ if (!afterFields.has(targetMapped)) findings.push({ code: 'DD2_INVARIANT_FIELD_MISSING', severity: 'error', message: 'BEFORE field-equal invariant "' + invariant.id + '" references missing target field ' + targetMapped, evidence: { invariant, targetMapped } })
967
+ }
968
+ }
969
+ return checkResult('DD2', 'Data Invariant Continuity', findings, findings.length === 0 ? 'All BEFORE data invariants continue to hold structurally' : 'Data invariant continuity findings: ' + findings.length)
970
+ }
971
+
972
+ function DD3_deltaSummary(before: DataModelV1, after: DataModelV1, options: DataVerificationOptions): CheckResult {
973
+ const findings: Finding[] = []
974
+ const afterEntities = new Set(after.entities.map((entity) => entity.name))
975
+ const beforeEntities = new Set(before.entities.map((entity) => entity.name))
976
+ const removedEntities = before.entities.filter((entity) => !afterEntities.has(entity.name)).map((entity) => entity.name)
977
+ const addedEntities = after.entities.filter((entity) => !beforeEntities.has(entity.name)).map((entity) => entity.name)
978
+ const mapping = options.fieldMapping ?? {}
979
+ const beforePaths = new Set(allFieldPaths(before))
980
+ const afterPaths = new Set(allFieldPaths(after))
981
+ const removedFields: string[] = []
982
+ const addedFields: string[] = []
983
+ for (const path of beforePaths) {
984
+ const mapped = mapFieldPath(mapping, path)
985
+ if (!afterPaths.has(mapped)) removedFields.push(path)
986
+ }
987
+ for (const path of afterPaths) {
988
+ let found = false
989
+ for (const beforePath of beforePaths) {
990
+ if (mapFieldPath(mapping, beforePath) === path) {
991
+ found = true
992
+ break
993
+ }
994
+ }
995
+ if (!found) addedFields.push(path)
996
+ }
997
+ for (const entity of removedEntities) findings.push({ code: 'DD3_REMOVED_ENTITY', severity: 'warning', message: 'BEFORE entity ' + entity + ' is not present in AFTER', evidence: { entity } })
998
+ for (const field of removedFields) findings.push({ code: 'DD3_REMOVED_FIELD', severity: 'warning', message: 'BEFORE field ' + field + ' is not present in AFTER', evidence: { field } })
999
+ const detail = 'Delta: +' + addedEntities.length + ' entities, -' + removedEntities.length + ' entities, +' + addedFields.length + ' fields, -' + removedFields.length + ' fields'
1000
+ return checkResult('DD3', 'Delta Summary', findings, detail)
1001
+ }
1002
+
1003
+ function DD4_breakingChangeRegression(before: DataModelV1, after: DataModelV1, options: DataVerificationOptions): CheckResult {
1004
+ const findings: Finding[] = []
1005
+ const afterFields = entityFieldMap(after)
1006
+ const mapping = options.fieldMapping ?? {}
1007
+ const migrationFrom = new Set((options.migrationMappings ?? []).map((mapping) => mapping.from))
1008
+ for (const entity of before.entities) {
1009
+ for (const field of entity.fields) {
1010
+ const path = entity.name + '.' + field.name
1011
+ const mapped = mapFieldPath(mapping, path)
1012
+ const afterField = afterFields.get(mapped)
1013
+ if (afterField === undefined) {
1014
+ if (field.required === true && !migrationFrom.has(path)) {
1015
+ findings.push({ code: 'DD4_REQUIRED_FIELD_REMOVED', severity: 'error', message: 'Required field ' + path + ' was removed without migration mapping', evidence: { beforeField: path } })
1016
+ }
1017
+ continue
1018
+ }
1019
+ if (field.required !== true && afterField.required === true && !migrationFrom.has(path)) {
1020
+ findings.push({ code: 'DD4_OPTIONAL_BECAME_REQUIRED', severity: 'warning', message: 'Optional field ' + path + ' became required in AFTER', evidence: { beforeField: path, afterField: mapped } })
1021
+ }
1022
+ if (field.nullable !== false && afterField.nullable === false && !migrationFrom.has(path)) {
1023
+ findings.push({ code: 'DD4_NULLABLE_BECAME_NOT_NULL', severity: 'warning', message: 'Nullable field ' + path + ' became non-nullable in AFTER', evidence: { beforeField: path, afterField: mapped } })
1024
+ }
1025
+ if (field.type !== afterField.type && !migrationFrom.has(path)) {
1026
+ findings.push({ code: 'DD4_TYPE_CHANGED', severity: 'warning', message: 'Field type changed from ' + field.type + ' to ' + afterField.type + ' for ' + path, evidence: { beforeField: path, beforeType: field.type, afterType: afterField.type } })
1027
+ }
1028
+ }
1029
+ }
1030
+ return checkResult('DD4', 'Breaking Change Regression', findings, findings.length === 0 ? 'No new breaking changes detected' : 'Breaking change findings: ' + findings.length)
1031
+ }
1032
+
1033
+ function buildDataComparisonSummary(before: DataModelV1, after: DataModelV1, options: DataVerificationOptions): DataComparisonSummary {
1034
+ const afterEntities = new Set(after.entities.map((entity) => entity.name))
1035
+ const beforeEntities = new Set(before.entities.map((entity) => entity.name))
1036
+ const addedEntities = after.entities.filter((entity) => !beforeEntities.has(entity.name)).map((entity) => entity.name).sort()
1037
+ const removedEntities = before.entities.filter((entity) => !afterEntities.has(entity.name)).map((entity) => entity.name).sort()
1038
+ const mapping = options.fieldMapping ?? {}
1039
+ const beforePaths = allFieldPaths(before)
1040
+ const afterPaths = allFieldPaths(after)
1041
+ const afterPathSet = new Set(afterPaths)
1042
+ const removedFields = beforePaths.filter((path) => !afterPathSet.has(mapFieldPath(mapping, path))).sort()
1043
+ const addedFields = afterPaths.filter((path) => !beforePaths.some((beforePath) => mapFieldPath(mapping, beforePath) === path)).sort()
1044
+ const beforeRelationships = (before.relationships ?? []).map((relationship) => relationship.name ?? relationship.fromEntity + '.' + relationship.fromField + '->' + relationship.toEntity)
1045
+ const afterRelationships = (after.relationships ?? []).map((relationship) => relationship.name ?? relationship.fromEntity + '.' + relationship.fromField + '->' + relationship.toEntity)
1046
+ const removedRelationships = beforeRelationships.filter((name) => !afterRelationships.includes(name)).sort()
1047
+ const addedRelationships = afterRelationships.filter((name) => !beforeRelationships.includes(name)).sort()
1048
+ return {
1049
+ beforeModelHash: dataModelHash(before),
1050
+ afterModelHash: dataModelHash(after),
1051
+ fieldMapping: mapping,
1052
+ beforeEntities: before.entities.length,
1053
+ beforeFields: beforePaths.length,
1054
+ afterEntities: after.entities.length,
1055
+ afterFields: afterPaths.length,
1056
+ addedEntities,
1057
+ removedEntities,
1058
+ addedFields,
1059
+ removedFields,
1060
+ addedRelationships,
1061
+ removedRelationships,
1062
+ }
1063
+ }
1064
+
1065
+ export function runDataVerification(input: unknown, options: DataVerificationOptions = {}): DataVerificationReport {
1066
+ const validation = validateDataModel(input)
1067
+ if (!validation.ok) {
1068
+ const findings: Finding[] = validation.errors.map((message) => ({ code: 'DATA_MODEL_INVALID', severity: 'error', message }))
1069
+ const modelCheck: CheckResult = {
1070
+ id: 'DATA_MODEL',
1071
+ name: 'Data Model Validation',
1072
+ status: 'fail',
1073
+ detail: 'Data model schema validation failed: ' + validation.errors.length + ' errors',
1074
+ findings,
1075
+ }
1076
+ return {
1077
+ ok: false,
1078
+ ran: true,
1079
+ ...verdictOfFindings(findings),
1080
+ schema: REPORT_SCHEMAS.datamodel,
1081
+ schemaVersion: 1,
1082
+ modelHash: '',
1083
+ hashes: { modelHash: '' },
1084
+ summary: { entities: 0, fields: 0, errors: validation.errors.length, warnings: 0, checksRun: 0 },
1085
+ checks: [modelCheck],
1086
+ nextSteps: dataNextSteps([modelCheck]),
1087
+ }
1088
+ }
1089
+ const model = validation.model
1090
+ const checks: CheckResult[] = [
1091
+ DS1_schemaWellFormed(model),
1092
+ DS2_requiredCompleteness(model, options),
1093
+ DS3_relationshipIntegrity(model),
1094
+ DS4_typeNullabilityConsistency(model),
1095
+ DS5_invariantReferences(model),
1096
+ DA1_nullEmptyInjection(model),
1097
+ DA2_boundaryBlast(model),
1098
+ DA3_uniquenessViolation(model),
1099
+ DA4_referentialIntegrityViolation(model),
1100
+ DA5_migrationCoverage(model, options),
1101
+ DA6_copyConsistency(model, options),
1102
+ DA7_rollbackBackupSymmetry(model, options),
1103
+ DA8_idempotentConstraints(model, options),
1104
+ DA9_dataMonotonic(model, options),
1105
+ DA10_dataSequence(model, options),
1106
+ DA11_dataLeadsTo(model, options),
1107
+ DA12_dataAtomicity(model, options),
1108
+ ]
1109
+ let comparison: DataComparisonSummary | undefined
1110
+ if (options.beforeModel !== undefined) {
1111
+ const beforeValidation = validateDataModel(options.beforeModel)
1112
+ if (!beforeValidation.ok) {
1113
+ const beforeFindings: Finding[] = beforeValidation.errors.map((message) => ({ code: 'BEFORE_DATA_MODEL_INVALID', severity: 'error', message }))
1114
+ return {
1115
+ ok: false,
1116
+ ran: true,
1117
+ ...verdictOfFindings([...checks.flatMap((check) => check.findings), ...beforeFindings]),
1118
+ schema: REPORT_SCHEMAS.datamodel,
1119
+ schemaVersion: 1,
1120
+ modelHash: dataModelHash(model),
1121
+ hashes: { modelHash: dataModelHash(model) },
1122
+ summary: {
1123
+ entities: model.entities.length,
1124
+ fields: allFieldPaths(model).length,
1125
+ errors: beforeValidation.errors.length,
1126
+ warnings: 0,
1127
+ checksRun: checks.length + 1,
1128
+ },
1129
+ checks: [
1130
+ ...checks,
1131
+ {
1132
+ id: 'BEFORE_DATA_MODEL',
1133
+ name: 'Before Data Model Validation',
1134
+ status: 'fail',
1135
+ detail: 'Before data model schema validation failed: ' + beforeValidation.errors.length + ' errors',
1136
+ findings: beforeFindings,
1137
+ },
1138
+ ],
1139
+ nextSteps: dataNextSteps(checks),
1140
+ }
1141
+ }
1142
+ const before = beforeValidation.model
1143
+ checks.push(
1144
+ DD1_dataBehaviorPreservation(before, model, options),
1145
+ DD2_dataInvariantContinuity(before, model, options),
1146
+ DD3_deltaSummary(before, model, options),
1147
+ DD4_breakingChangeRegression(before, model, options),
1148
+ )
1149
+ comparison = buildDataComparisonSummary(before, model, options)
1150
+ }
1151
+ // A check whose vocabulary the model never declared examined nothing, and says so. It has to run before
1152
+ // the summary and `nextSteps` are derived, or both keep counting it as a run.
1153
+ for (const check of checks) {
1154
+ if (check.status !== 'pass') continue
1155
+ const why = nothingToExamine(check.id, model, options)
1156
+ if (why === undefined) continue
1157
+ check.status = 'skip'
1158
+ check.detail = 'skipped: ' + why
1159
+ }
1160
+ const errors = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'error').length, 0)
1161
+ const warnings = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'warning').length, 0)
1162
+ return {
1163
+ ok: true,
1164
+ ran: true,
1165
+ ...verdictOfFindings(checks.flatMap((check) => check.findings)),
1166
+ schema: REPORT_SCHEMAS.datamodel,
1167
+ schemaVersion: 1,
1168
+ modelHash: dataModelHash(model),
1169
+ hashes: { modelHash: dataModelHash(model) },
1170
+ summary: {
1171
+ entities: model.entities.length,
1172
+ fields: allFieldPaths(model).length,
1173
+ errors,
1174
+ warnings,
1175
+ // What the name promises: the checks that examined the model, not every check object.
1176
+ checksRun: checks.filter((check) => check.status !== 'skip').length,
1177
+ ...(() => {
1178
+ const skipped = checks.filter((check) => check.status === 'skip').map((check) => check.id)
1179
+ return skipped.length === 0 ? {} : { checksSkipped: skipped }
1180
+ })(),
1181
+ },
1182
+ checks,
1183
+ nextSteps: dataNextSteps(checks),
1184
+ ...(comparison === undefined ? {} : { comparison }),
1185
+ }
1186
+ }
1187
+
1188
+ /**
1189
+ * The next-steps list every data report carries, derived only from the checks so two
1190
+ * runs over the same model produce the same list.
1191
+ */
1192
+ function dataNextSteps(checks: CheckResult[]): string[] {
1193
+ const steps: string[] = []
1194
+ // Name first, code in parentheses: the sentence has to be actionable for a reader who has not seen a
1195
+ // report yet, and the code stays available for one who has.
1196
+ const failed = checks.filter((check) => check.status === 'fail').map((check) => check.name + ' (' + check.id + ')')
1197
+ const warningCodes = [...new Set(checks.flatMap((check) => check.findings).filter((finding) => finding.severity === 'warning').map((finding) => finding.code))].sort()
1198
+ if (failed.length > 0) steps.push('Resolve the failing checks first: ' + failed.join(', ') + '.')
1199
+ // A check with nothing to examine is named, because a reader who is not told will assume every named
1200
+ // check looked at the model. `pass` does not say that, and neither does a list of the failures.
1201
+ const skipped = checks.filter((check) => check.status === 'skip').map((check) => check.name + ' (' + check.id + ')')
1202
+ if (skipped.length > 0) {
1203
+ steps.push('Not checked: ' + skipped.join(', ') + ' examined nothing — silence there is not a pass. Declare what each needs, or treat the model as unverified on those points.')
1204
+ }
1205
+ if (warningCodes.length > 0) steps.push('Review the warning findings (' + warningCodes.join(', ') + '): a nullable field or a missing index is a decision, not silence.')
1206
+ steps.push('Re-run with beforeModel plus the mapping/copy/migration options after the change to prove the data behaviour did not regress: migration coverage, copy consistency, rollback symmetry, and the before/after comparison. Then compare the two reports with `report_diff` rather than reading them side by side.')
1207
+ return steps
1208
+ }