dsh-logicprobe 0.4.0 → 0.5.1

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 (33) hide show
  1. package/README.en-US.md +10 -3
  2. package/README.md +10 -3
  3. package/lib/concurrency-tool.js +34 -0
  4. package/lib/concurrency.js +76 -0
  5. package/lib/data-engine.js +930 -0
  6. package/lib/data-tool.js +61 -0
  7. package/lib/engine.js +258 -0
  8. package/lib/index.js +37 -23
  9. package/lib/tool.js +1 -1
  10. package/lib/types/concurrency-tool.d.ts +8 -0
  11. package/lib/types/concurrency.d.ts +20 -0
  12. package/lib/types/data-engine.d.ts +199 -0
  13. package/lib/types/data-tool.d.ts +10 -0
  14. package/lib/types/engine.d.ts +20 -0
  15. package/package.json +82 -81
  16. package/skills/logicprobe/SKILL.md +285 -268
  17. package/skills/logicprobe/references/__pycache__/verification-harness.cpython-312.pyc +0 -0
  18. package/skills/logicprobe/references/concurrency-risk-guide.md +54 -0
  19. package/skills/logicprobe/references/dsh-model-schema.md +145 -129
  20. package/skills/logicprobe/references/logic-verification-guide.md +463 -413
  21. package/skills/logicprobe/references/verification-harness.py +806 -582
  22. package/skills/logicprobe-datamodel/SKILL.md +124 -0
  23. package/skills/logicprobe-datamodel/references/__pycache__/data-model-harness.cpython-312.pyc +0 -0
  24. package/skills/logicprobe-datamodel/references/data-model-guide.md +62 -0
  25. package/skills/logicprobe-datamodel/references/data-model-harness.py +528 -0
  26. package/skills/logicprobe-datamodel/references/data-model-schema.md +128 -0
  27. package/src/concurrency-tool.ts +37 -0
  28. package/src/concurrency.ts +102 -0
  29. package/src/data-engine.ts +1001 -0
  30. package/src/data-tool.ts +65 -0
  31. package/src/engine.ts +234 -0
  32. package/src/index.ts +315 -301
  33. package/src/tool.ts +60 -60
@@ -0,0 +1,1001 @@
1
+ import { createHash } from 'node:crypto'
2
+ import type { CheckResult, Finding } 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
+ ok: boolean
133
+ schemaVersion: 1
134
+ modelHash: string
135
+ summary: {
136
+ entities: number
137
+ fields: number
138
+ errors: number
139
+ warnings: number
140
+ checksRun: number
141
+ truncated?: boolean
142
+ }
143
+ checks: CheckResult[]
144
+ comparison?: DataComparisonSummary
145
+ }
146
+
147
+ const KNOWN_TYPES = new Set<string>([
148
+ 'string', 'integer', 'number', 'boolean', 'uuid', 'date', 'datetime', 'timestamp',
149
+ 'json', 'enum', 'array', 'object', 'binary',
150
+ ])
151
+
152
+ function stableStringify(value: unknown): string {
153
+ if (value === null || typeof value !== 'object') return JSON.stringify(value)
154
+ if (Array.isArray(value)) return '[' + value.map((item) => stableStringify(item)).join(',') + ']'
155
+ const record = value as Record<string, unknown>
156
+ return '{' + Object.keys(record).sort().map((key) => JSON.stringify(key) + ':' + stableStringify(record[key])).join(',') + '}'
157
+ }
158
+
159
+ export function dataModelHash(model: DataModelV1): string {
160
+ return createHash('sha256').update(stableStringify(model)).digest('hex')
161
+ }
162
+
163
+ function bad(errors: string[], path: string, message: string): void {
164
+ errors.push(path + ': ' + message)
165
+ }
166
+
167
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
168
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
169
+ }
170
+
171
+ function validateField(input: unknown, path: string, errors: string[], fieldNames: Set<string>): FieldSpec | undefined {
172
+ if (!isPlainObject(input)) {
173
+ bad(errors, path, 'must be an object')
174
+ return undefined
175
+ }
176
+ if (typeof input.name !== 'string' || input.name.length === 0) {
177
+ bad(errors, path + '.name', 'must be a non-empty string')
178
+ } else if (fieldNames.has(input.name)) {
179
+ bad(errors, path + '.name', 'duplicate field name ' + input.name)
180
+ } else {
181
+ fieldNames.add(input.name)
182
+ }
183
+ if (typeof input.type !== 'string' || input.type.length === 0) {
184
+ bad(errors, path + '.type', 'must be a non-empty string')
185
+ }
186
+ if (input.required !== undefined && typeof input.required !== 'boolean') bad(errors, path + '.required', 'must be a boolean')
187
+ if (input.unique !== undefined && typeof input.unique !== 'boolean') bad(errors, path + '.unique', 'must be a boolean')
188
+ if (input.nullable !== undefined && typeof input.nullable !== 'boolean') bad(errors, path + '.nullable', 'must be a boolean')
189
+ if (input.min !== undefined && typeof input.min !== 'number') bad(errors, path + '.min', 'must be a number')
190
+ if (input.max !== undefined && typeof input.max !== 'number') bad(errors, path + '.max', 'must be a number')
191
+ if (input.minLength !== undefined && typeof input.minLength !== 'number') bad(errors, path + '.minLength', 'must be a number')
192
+ if (input.maxLength !== undefined && typeof input.maxLength !== 'number') bad(errors, path + '.maxLength', 'must be a number')
193
+ if (input.monotonic !== undefined && input.monotonic !== 'inc' && input.monotonic !== 'dec') bad(errors, path + '.monotonic', "must be 'inc' or 'dec'")
194
+ if (input.pattern !== undefined && typeof input.pattern !== 'string') bad(errors, path + '.pattern', 'must be a string')
195
+ if (input.ref !== undefined && typeof input.ref !== 'string') bad(errors, path + '.ref', 'must be a string')
196
+ if (input.enum !== undefined && !Array.isArray(input.enum)) bad(errors, path + '.enum', 'must be an array')
197
+ if (input.items !== undefined) validateField(input.items, path + '.items', errors, new Set<string>())
198
+ return input as unknown as FieldSpec
199
+ }
200
+
201
+ function entityByName(model: DataModelV1, name: string): EntitySpec | undefined {
202
+ return model.entities.find((entity) => entity.name === name)
203
+ }
204
+
205
+ function fieldByPath(model: DataModelV1, entityName: string, fieldName: string): FieldSpec | undefined {
206
+ return entityByName(model, entityName)?.fields.find((field) => field.name === fieldName)
207
+ }
208
+
209
+ function mapFieldPath(mapping: Record<string, string>, path: string): string {
210
+ return mapping[path] ?? path
211
+ }
212
+
213
+ export function validateDataModel(input: unknown): { ok: true; model: DataModelV1 } | { ok: false; errors: string[] } {
214
+ const errors: string[] = []
215
+ if (!isPlainObject(input)) {
216
+ return { ok: false, errors: ['model: must be an object'] }
217
+ }
218
+ const root = input as Record<string, unknown>
219
+ if (root.schemaVersion !== 1) bad(errors, 'schemaVersion', 'must be 1')
220
+ if (!Array.isArray(root.entities) || root.entities.length === 0) {
221
+ bad(errors, 'entities', 'must be a non-empty array')
222
+ } else {
223
+ const entityNames = new Set<string>()
224
+ root.entities.forEach((entry, index) => {
225
+ const path = 'entities[' + index + ']'
226
+ if (!isPlainObject(entry)) {
227
+ bad(errors, path, 'must be an object')
228
+ return
229
+ }
230
+ if (typeof entry.name !== 'string' || entry.name.length === 0) {
231
+ bad(errors, path + '.name', 'must be a non-empty string')
232
+ } else if (entityNames.has(entry.name)) {
233
+ bad(errors, path + '.name', 'duplicate entity name ' + entry.name)
234
+ } else {
235
+ entityNames.add(entry.name)
236
+ }
237
+ if (!Array.isArray(entry.fields) || entry.fields.length === 0) {
238
+ bad(errors, path + '.fields', 'must be a non-empty array')
239
+ } else {
240
+ const fieldNames = new Set<string>()
241
+ entry.fields.forEach((field, fieldIndex) => {
242
+ validateField(field, path + '.fields[' + fieldIndex + ']', errors, fieldNames)
243
+ })
244
+ }
245
+ if (entry.primaryKey !== undefined) {
246
+ if (!Array.isArray(entry.primaryKey)) bad(errors, path + '.primaryKey', 'must be an array')
247
+ }
248
+ if (entry.uniqueKeys !== undefined) {
249
+ if (!Array.isArray(entry.uniqueKeys)) bad(errors, path + '.uniqueKeys', 'must be an array')
250
+ }
251
+ if (entry.indexes !== undefined) {
252
+ if (!Array.isArray(entry.indexes)) bad(errors, path + '.indexes', 'must be an array')
253
+ }
254
+ })
255
+ }
256
+ if (root.relationships !== undefined) {
257
+ if (!Array.isArray(root.relationships)) bad(errors, 'relationships', 'must be an array')
258
+ }
259
+ if (root.invariants !== undefined) {
260
+ if (!Array.isArray(root.invariants)) bad(errors, 'invariants', 'must be an array')
261
+ else root.invariants.forEach((entry, index) => {
262
+ const path = 'invariants[' + index + ']'
263
+ if (!isPlainObject(entry)) {
264
+ bad(errors, path, 'must be an object')
265
+ return
266
+ }
267
+ if (typeof entry.id !== 'string' || entry.id.length === 0) bad(errors, path + '.id', 'must be a non-empty string')
268
+ if (typeof entry.description !== 'string') bad(errors, path + '.description', 'must be a string')
269
+ const kind = entry.kind
270
+ if (kind === 'field-required' || kind === 'no-orphan') {
271
+ if (typeof entry.entity !== 'string') bad(errors, path + '.entity', 'must be a string')
272
+ if (typeof entry.field !== 'string') bad(errors, path + '.field', 'must be a string')
273
+ } else if (kind === 'unique') {
274
+ if (!Array.isArray(entry.fields)) bad(errors, path + '.fields', 'must be an array')
275
+ } else if (kind === 'referential-integrity') {
276
+ if (typeof entry.entity !== 'string') bad(errors, path + '.entity', 'must be a string')
277
+ if (typeof entry.field !== 'string') bad(errors, path + '.field', 'must be a string')
278
+ if (typeof entry.refEntity !== 'string') bad(errors, path + '.refEntity', 'must be a string')
279
+ if (entry.refField !== undefined && typeof entry.refField !== 'string') bad(errors, path + '.refField', 'must be a string')
280
+ } else if (kind === 'range') {
281
+ if (typeof entry.entity !== 'string') bad(errors, path + '.entity', 'must be a string')
282
+ if (typeof entry.field !== 'string') bad(errors, path + '.field', 'must be a string')
283
+ if (entry.min !== undefined && typeof entry.min !== 'number') bad(errors, path + '.min', 'must be a number')
284
+ if (entry.max !== undefined && typeof entry.max !== 'number') bad(errors, path + '.max', 'must be a number')
285
+ } else if (kind === 'count-equal') {
286
+ if (typeof entry.sourceEntity !== 'string') bad(errors, path + '.sourceEntity', 'must be a string')
287
+ if (typeof entry.targetEntity !== 'string') bad(errors, path + '.targetEntity', 'must be a string')
288
+ } else if (kind === 'field-equal') {
289
+ if (typeof entry.sourceEntity !== 'string') bad(errors, path + '.sourceEntity', 'must be a string')
290
+ if (typeof entry.sourceField !== 'string') bad(errors, path + '.sourceField', 'must be a string')
291
+ if (typeof entry.targetEntity !== 'string') bad(errors, path + '.targetEntity', 'must be a string')
292
+ if (typeof entry.targetField !== 'string') bad(errors, path + '.targetField', 'must be a string')
293
+ } else if (kind === 'idempotent-copy') {
294
+ if (typeof entry.sourceEntity !== 'string') bad(errors, path + '.sourceEntity', 'must be a string')
295
+ if (typeof entry.targetEntity !== 'string') bad(errors, path + '.targetEntity', 'must be a string')
296
+ } else if (kind === 'idempotent-migration') {
297
+ if (typeof entry.from !== 'string') bad(errors, path + '.from', 'must be a string')
298
+ if (typeof entry.to !== 'string') bad(errors, path + '.to', 'must be a string')
299
+ } else if (kind === 'monotonic') {
300
+ if (typeof entry.entity !== 'string') bad(errors, path + '.entity', 'must be a string')
301
+ if (typeof entry.field !== 'string') bad(errors, path + '.field', 'must be a string')
302
+ if (entry.direction !== 'inc' && entry.direction !== 'dec') bad(errors, path + '.direction', "must be 'inc' or 'dec'")
303
+ } else if (kind === 'sequence') {
304
+ if (!Array.isArray(entry.steps) || entry.steps.length === 0) bad(errors, path + '.steps', 'must be a non-empty array')
305
+ else entry.steps.forEach((step, stepIndex) => {
306
+ if (typeof step !== 'string' || step.length === 0) bad(errors, path + '.steps[' + stepIndex + ']', 'must be a non-empty string')
307
+ })
308
+ } else if (kind === 'leads-to') {
309
+ if (typeof entry.entity !== 'string') bad(errors, path + '.entity', 'must be a string')
310
+ if (typeof entry.field !== 'string') bad(errors, path + '.field', 'must be a string')
311
+ } else if (kind === 'atomicity') {
312
+ if (!Array.isArray(entry.steps) || entry.steps.length === 0) bad(errors, path + '.steps', 'must be a non-empty array')
313
+ else entry.steps.forEach((step, stepIndex) => {
314
+ if (typeof step !== 'string' || step.length === 0) bad(errors, path + '.steps[' + stepIndex + ']', 'must be a non-empty string')
315
+ })
316
+ } else {
317
+ bad(errors, path + '.kind', 'unknown invariant kind')
318
+ }
319
+ })
320
+ }
321
+ if (root.boundaryChecks !== undefined) {
322
+ if (!Array.isArray(root.boundaryChecks)) bad(errors, 'boundaryChecks', 'must be an array')
323
+ }
324
+ if (errors.length > 0) return { ok: false, errors }
325
+ return { ok: true, model: root as unknown as DataModelV1 }
326
+ }
327
+
328
+ function checkResult(id: string, name: string, findings: Finding[], detail: string): CheckResult {
329
+ const errors = findings.filter((finding) => finding.severity === 'error').length
330
+ const warnings = findings.filter((finding) => finding.severity === 'warning').length
331
+ return {
332
+ id,
333
+ name,
334
+ status: findings.length === 0 ? 'pass' : 'fail',
335
+ detail: detail + (errors > 0 ? ' (' + errors + ' errors' + (warnings > 0 ? ', ' + warnings + ' warnings' : '') + ')' : warnings > 0 ? ' (' + warnings + ' warnings)' : ''),
336
+ findings,
337
+ }
338
+ }
339
+
340
+ function allFieldPaths(model: DataModelV1): string[] {
341
+ const result: string[] = []
342
+ for (const entity of model.entities) {
343
+ for (const field of entity.fields) result.push(entity.name + '.' + field.name)
344
+ }
345
+ return result
346
+ }
347
+
348
+ function entityFieldMap(model: DataModelV1): Map<string, FieldSpec> {
349
+ const map = new Map<string, FieldSpec>()
350
+ for (const entity of model.entities) {
351
+ for (const field of entity.fields) map.set(entity.name + '.' + field.name, field)
352
+ }
353
+ return map
354
+ }
355
+
356
+ function DS1_schemaWellFormed(model: DataModelV1): CheckResult {
357
+ const findings: Finding[] = []
358
+ const entityNames = new Set<string>()
359
+ for (const entity of model.entities) {
360
+ if (entityNames.has(entity.name)) {
361
+ findings.push({ code: 'DS1_DUPLICATE_ENTITY', severity: 'error', message: 'Duplicate entity name ' + entity.name, evidence: { entity: entity.name } })
362
+ }
363
+ entityNames.add(entity.name)
364
+ const fieldNames = new Set<string>()
365
+ for (const field of entity.fields) {
366
+ if (fieldNames.has(field.name)) {
367
+ findings.push({ code: 'DS1_DUPLICATE_FIELD', severity: 'error', message: 'Duplicate field ' + entity.name + '.' + field.name, evidence: { entity: entity.name, field: field.name } })
368
+ }
369
+ fieldNames.add(field.name)
370
+ if (field.type !== undefined && !KNOWN_TYPES.has(field.type) && field.type.trim().length === 0) {
371
+ findings.push({ code: 'DS1_EMPTY_TYPE', severity: 'error', message: 'Field ' + entity.name + '.' + field.name + ' has empty type', evidence: { entity: entity.name, field: field.name } })
372
+ }
373
+ }
374
+ for (const key of entity.primaryKey ?? []) {
375
+ 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 } })
376
+ }
377
+ for (const keySet of entity.uniqueKeys ?? []) {
378
+ for (const key of keySet) {
379
+ 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 } })
380
+ }
381
+ }
382
+ for (const keySet of entity.indexes ?? []) {
383
+ for (const key of keySet) {
384
+ 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 } })
385
+ }
386
+ }
387
+ }
388
+ return checkResult('DS1', 'Schema Well-Formedness', findings, findings.length === 0 ? 'Schema is well-formed' : 'Schema findings: ' + findings.length)
389
+ }
390
+
391
+ function DS2_requiredCompleteness(model: DataModelV1, options: DataVerificationOptions): CheckResult {
392
+ const findings: Finding[] = []
393
+ const afterFields = entityFieldMap(model)
394
+ const migrationFrom = new Set((options.migrationMappings ?? []).map((mapping) => mapping.from))
395
+ const copyTargets = new Set<string>()
396
+ for (const pair of options.copyPairs ?? []) {
397
+ for (const target of Object.values(pair.mapping)) copyTargets.add(pair.targetEntity + '.' + target)
398
+ }
399
+ const beforePaths = new Set<string>()
400
+ if (options.beforeModel !== undefined) {
401
+ const beforeValidation = validateDataModel(options.beforeModel)
402
+ if (beforeValidation.ok) {
403
+ const mapping = options.fieldMapping ?? {}
404
+ for (const path of allFieldPaths(beforeValidation.model)) beforePaths.add(mapFieldPath(mapping, path))
405
+ }
406
+ }
407
+ for (const entity of model.entities) {
408
+ for (const field of entity.fields) {
409
+ if (field.required !== true) continue
410
+ if ((entity.primaryKey ?? []).includes(field.name) && field.nullable === true) {
411
+ 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 } })
412
+ }
413
+ if (options.beforeModel !== undefined) {
414
+ const path = entity.name + '.' + field.name
415
+ if (!beforePaths.has(path) && !migrationFrom.has(path) && !copyTargets.has(path)) {
416
+ 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 } })
417
+ }
418
+ }
419
+ }
420
+ }
421
+ return checkResult('DS2', 'Required-Field Completeness', findings, findings.length === 0 ? 'Required fields are complete' : 'Required-field findings: ' + findings.length)
422
+ }
423
+
424
+ function DS3_relationshipIntegrity(model: DataModelV1): CheckResult {
425
+ const findings: Finding[] = []
426
+ for (const relationship of model.relationships ?? []) {
427
+ const fromEntity = entityByName(model, relationship.fromEntity)
428
+ const toEntity = entityByName(model, relationship.toEntity)
429
+ if (fromEntity === undefined) {
430
+ findings.push({ code: 'DS3_FROM_ENTITY_MISSING', severity: 'error', message: 'Relationship references unknown fromEntity ' + relationship.fromEntity, evidence: { relationship } })
431
+ continue
432
+ }
433
+ if (toEntity === undefined) {
434
+ findings.push({ code: 'DS3_TO_ENTITY_MISSING', severity: 'error', message: 'Relationship references unknown toEntity ' + relationship.toEntity, evidence: { relationship } })
435
+ continue
436
+ }
437
+ if (!fromEntity.fields.some((field) => field.name === relationship.fromField)) {
438
+ findings.push({ code: 'DS3_FROM_FIELD_MISSING', severity: 'error', message: 'Relationship field ' + relationship.fromField + ' does not exist in ' + relationship.fromEntity, evidence: { relationship } })
439
+ }
440
+ if (relationship.toField !== undefined && !toEntity.fields.some((field) => field.name === relationship.toField)) {
441
+ findings.push({ code: 'DS3_TO_FIELD_MISSING', severity: 'error', message: 'Relationship target field ' + relationship.toField + ' does not exist in ' + relationship.toEntity, evidence: { relationship } })
442
+ }
443
+ if (relationship.onDelete !== undefined && !['cascade', 'restrict', 'set-null', 'no-action'].includes(relationship.onDelete)) {
444
+ findings.push({ code: 'DS3_INVALID_ON_DELETE', severity: 'error', message: 'Invalid onDelete value ' + relationship.onDelete, evidence: { relationship } })
445
+ }
446
+ }
447
+ return checkResult('DS3', 'Relationship Integrity', findings, findings.length === 0 ? 'Relationships are valid' : 'Relationship findings: ' + findings.length)
448
+ }
449
+
450
+ function DS4_typeNullabilityConsistency(model: DataModelV1): CheckResult {
451
+ const findings: Finding[] = []
452
+ for (const entity of model.entities) {
453
+ for (const field of entity.fields) {
454
+ if (field.required === true && field.nullable === true) {
455
+ 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 } })
456
+ }
457
+ if (field.unique === true && field.nullable === true) {
458
+ 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 } })
459
+ }
460
+ if (typeof field.min === 'number' && typeof field.max === 'number' && field.min > field.max) {
461
+ 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 } })
462
+ }
463
+ if (typeof field.minLength === 'number' && typeof field.maxLength === 'number' && field.minLength > field.maxLength) {
464
+ 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 } })
465
+ }
466
+ if (field.default === null && field.required === true) {
467
+ 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 } })
468
+ }
469
+ }
470
+ }
471
+ return checkResult('DS4', 'Type/Nullability Consistency', findings, findings.length === 0 ? 'Types and nullability are consistent' : 'Type/nullability findings: ' + findings.length)
472
+ }
473
+
474
+ function DA1_nullEmptyInjection(model: DataModelV1): CheckResult {
475
+ const findings: Finding[] = []
476
+ for (const entity of model.entities) {
477
+ for (const field of entity.fields) {
478
+ if (field.required === true && field.nullable === true) {
479
+ 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 } })
480
+ }
481
+ if (field.required === true && field.type === 'string' && field.minLength !== undefined && field.minLength === 0) {
482
+ 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 } })
483
+ }
484
+ }
485
+ }
486
+ return checkResult('DA1', 'Null/Empty Injection', findings, findings.length === 0 ? 'Null/empty injection is rejected' : 'Null/empty findings: ' + findings.length)
487
+ }
488
+
489
+ function DA2_boundaryBlast(model: DataModelV1): CheckResult {
490
+ const findings: Finding[] = []
491
+ for (const entity of model.entities) {
492
+ for (const field of entity.fields) {
493
+ if (typeof field.min === 'number' && typeof field.max === 'number' && field.min > field.max) continue
494
+ if (typeof field.default === 'number') {
495
+ 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 } })
496
+ 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 } })
497
+ }
498
+ if (field.enum !== undefined && field.enum.length > 0) {
499
+ if (typeof field.min === 'number') {
500
+ for (const value of field.enum) {
501
+ 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 } })
502
+ }
503
+ }
504
+ if (typeof field.max === 'number') {
505
+ for (const value of field.enum) {
506
+ 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 } })
507
+ }
508
+ }
509
+ }
510
+ }
511
+ }
512
+ for (const check of model.boundaryChecks ?? []) {
513
+ const field = fieldByPath(model, check.entity, check.field)
514
+ if (field === undefined) {
515
+ 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 } })
516
+ continue
517
+ }
518
+ for (const value of check.values ?? []) {
519
+ if (typeof value === 'number') {
520
+ 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 } })
521
+ 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 } })
522
+ }
523
+ if (field.enum !== undefined && field.enum.length > 0 && !field.enum.includes(value)) {
524
+ 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 } })
525
+ }
526
+ }
527
+ }
528
+ return checkResult('DA2', 'Boundary Blast', findings, findings.length === 0 ? 'Boundary checks passed' : 'Boundary findings: ' + findings.length)
529
+ }
530
+
531
+ function DA3_uniquenessViolation(model: DataModelV1): CheckResult {
532
+ const findings: Finding[] = []
533
+ for (const entity of model.entities) {
534
+ for (const key of entity.primaryKey ?? []) {
535
+ const field = fieldByPath(model, entity.name, key)
536
+ 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 } })
537
+ }
538
+ for (const keySet of entity.uniqueKeys ?? []) {
539
+ for (const key of keySet) {
540
+ const field = fieldByPath(model, entity.name, key)
541
+ 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 } })
542
+ }
543
+ }
544
+ }
545
+ return checkResult('DA3', 'Uniqueness Violation', findings, findings.length === 0 ? 'No uniqueness violations' : 'Uniqueness findings: ' + findings.length)
546
+ }
547
+
548
+ function DA4_referentialIntegrityViolation(model: DataModelV1): CheckResult {
549
+ const findings: Finding[] = []
550
+ for (const relationship of model.relationships ?? []) {
551
+ const fromField = fieldByPath(model, relationship.fromEntity, relationship.fromField)
552
+ const toField = relationship.toField === undefined ? undefined : fieldByPath(model, relationship.toEntity, relationship.toField)
553
+ if (fromField !== undefined && toField !== undefined && fromField.type !== toField.type) {
554
+ 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 } })
555
+ }
556
+ }
557
+ return checkResult('DA4', 'Referential Integrity Violation', findings, findings.length === 0 ? 'No referential integrity violations' : 'Referential integrity findings: ' + findings.length)
558
+ }
559
+
560
+ function DA5_migrationCoverage(model: DataModelV1, options: DataVerificationOptions): CheckResult {
561
+ const findings: Finding[] = []
562
+ if (options.beforeModel === undefined) {
563
+ return checkResult('DA5', 'Migration Coverage', [], 'No beforeModel supplied — skipped')
564
+ }
565
+ const beforeValidation = validateDataModel(options.beforeModel)
566
+ if (!beforeValidation.ok) {
567
+ return checkResult('DA5', 'Migration Coverage', [{ code: 'DA5_BEFORE_MODEL_INVALID', severity: 'error', message: 'Before model failed validation: ' + beforeValidation.errors.join('; ') }], 'Before model invalid')
568
+ }
569
+ const before = beforeValidation.model
570
+ const afterPaths = new Set(allFieldPaths(model))
571
+ const beforePaths = allFieldPaths(before)
572
+ const mapping = options.fieldMapping ?? {}
573
+ const migrationFrom = new Set((options.migrationMappings ?? []).map((mapping) => mapping.from))
574
+ for (const path of beforePaths) {
575
+ const mapped = mapFieldPath(mapping, path)
576
+ if (!afterPaths.has(mapped) && !migrationFrom.has(path)) {
577
+ 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 } })
578
+ }
579
+ }
580
+ return checkResult('DA5', 'Migration Coverage', findings, findings.length === 0 ? 'All before/after fields are covered by mappings' : 'Migration coverage findings: ' + findings.length)
581
+ }
582
+
583
+ function DA6_copyConsistency(model: DataModelV1, options: DataVerificationOptions): CheckResult {
584
+ const findings: Finding[] = []
585
+ const afterFields = entityFieldMap(model)
586
+ for (const pair of options.copyPairs ?? []) {
587
+ const targetEntity = entityByName(model, pair.targetEntity)
588
+ if (targetEntity === undefined) {
589
+ findings.push({ code: 'DA6_TARGET_ENTITY_MISSING', severity: 'error', message: 'Copy pair ' + pair.id + ' references unknown target entity ' + pair.targetEntity, evidence: { pair } })
590
+ continue
591
+ }
592
+ if (entityByName(model, pair.sourceEntity) === undefined) {
593
+ findings.push({ code: 'DA6_SOURCE_ENTITY_MISSING', severity: 'error', message: 'Copy pair ' + pair.id + ' references unknown source entity ' + pair.sourceEntity, evidence: { pair } })
594
+ }
595
+ const mappedTargets = new Set(Object.values(pair.mapping).map((field) => pair.targetEntity + '.' + field))
596
+ for (const field of targetEntity.fields) {
597
+ if (field.required === true && !mappedTargets.has(pair.targetEntity + '.' + field.name)) {
598
+ 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 } })
599
+ }
600
+ }
601
+ for (const [sourceField, targetField] of Object.entries(pair.mapping)) {
602
+ if (fieldByPath(model, pair.sourceEntity, sourceField) === undefined) {
603
+ 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 } })
604
+ }
605
+ if (afterFields.has(pair.targetEntity + '.' + targetField) === false) {
606
+ 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 } })
607
+ }
608
+ }
609
+ }
610
+ return checkResult('DA6', 'Copy Consistency', findings, findings.length === 0 ? 'Copy mappings are consistent' : 'Copy consistency findings: ' + findings.length)
611
+ }
612
+
613
+ function DA7_rollbackBackupSymmetry(model: DataModelV1, options: DataVerificationOptions): CheckResult {
614
+ const findings: Finding[] = []
615
+ const backupKeys = new Set((options.backupPairs ?? []).map((pair) => pair.sourceEntity + '|' + pair.targetEntity))
616
+ for (const pair of options.copyPairs ?? []) {
617
+ if (!backupKeys.has(pair.sourceEntity + '|' + pair.targetEntity)) {
618
+ 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 } })
619
+ }
620
+ }
621
+ return checkResult('DA7', 'Rollback/Backup Symmetry', findings, findings.length === 0 ? 'Copy/backup pairs are balanced' : 'Rollback/backup findings: ' + findings.length)
622
+ }
623
+
624
+ function DA8_idempotentConstraints(model: DataModelV1, options: DataVerificationOptions): CheckResult {
625
+ const findings: Finding[] = []
626
+ const copyPairs = options.copyPairs ?? []
627
+ const migrationMappings = options.migrationMappings ?? []
628
+ for (const invariant of model.invariants ?? []) {
629
+ if (invariant.kind === 'idempotent-copy') {
630
+ const pair = copyPairs.find((entry) => entry.sourceEntity === invariant.sourceEntity && entry.targetEntity === invariant.targetEntity)
631
+ if (pair === undefined) {
632
+ findings.push({
633
+ code: 'DA8_COPY_PAIR_MISSING',
634
+ severity: 'error',
635
+ message: 'Idempotent-copy invariant "' + invariant.id + '" has no matching copy pair for ' + invariant.sourceEntity + ' -> ' + invariant.targetEntity,
636
+ evidence: { invariant },
637
+ })
638
+ }
639
+ } else if (invariant.kind === 'idempotent-migration') {
640
+ const mapping = migrationMappings.find((entry) => entry.from === invariant.from && entry.to === invariant.to)
641
+ if (mapping === undefined) {
642
+ findings.push({
643
+ code: 'DA8_MIGRATION_MAPPING_MISSING',
644
+ severity: 'error',
645
+ message: 'Idempotent-migration invariant "' + invariant.id + '" has no matching migration mapping ' + invariant.from + ' -> ' + invariant.to,
646
+ evidence: { invariant },
647
+ })
648
+ } else if (mapping.transform === 'split' || mapping.transform === 'merge' || mapping.transform === 'drop') {
649
+ findings.push({
650
+ code: 'DA8_NON_IDEMPOTENT_TRANSFORM',
651
+ severity: 'warning',
652
+ message: 'Migration mapping ' + invariant.from + ' -> ' + invariant.to + ' uses non-idempotent transform ' + (mapping.transform ?? 'unknown'),
653
+ evidence: { invariant, mapping },
654
+ })
655
+ }
656
+ }
657
+ }
658
+ return checkResult('DA8', 'Idempotent Constraints', findings, findings.length === 0 ? 'Idempotency constraints are satisfied' : 'Idempotency findings: ' + findings.length)
659
+ }
660
+
661
+ function dataStepRegistry(options: DataVerificationOptions): Map<string, { kind: 'copy' | 'migration'; transform?: string; copyPair?: CopyPairSpec }> {
662
+ const map = new Map<string, { kind: 'copy' | 'migration'; transform?: string; copyPair?: CopyPairSpec }>()
663
+ for (const pair of options.copyPairs ?? []) map.set(pair.id, { kind: 'copy', copyPair: pair })
664
+ for (const mapping of options.migrationMappings ?? []) {
665
+ const id = mapping.id ?? mapping.from + '->' + mapping.to
666
+ map.set(id, { kind: 'migration', transform: mapping.transform })
667
+ }
668
+ return map
669
+ }
670
+
671
+ const MONOTONIC_DATA_TYPES = new Set<string>(['integer', 'number', 'date', 'datetime', 'timestamp'])
672
+
673
+ function DA9_dataMonotonic(model: DataModelV1, options: DataVerificationOptions): CheckResult {
674
+ const findings: Finding[] = []
675
+ for (const invariant of model.invariants ?? []) {
676
+ if (invariant.kind !== 'monotonic') continue
677
+ const field = fieldByPath(model, invariant.entity, invariant.field)
678
+ if (field === undefined) {
679
+ findings.push({ code: 'DA9_MONOTONIC_FIELD_MISSING', severity: 'error', message: 'Monotonic invariant "' + invariant.id + '" references missing field ' + invariant.entity + '.' + invariant.field, evidence: { invariant } })
680
+ continue
681
+ }
682
+ if (!MONOTONIC_DATA_TYPES.has(field.type)) {
683
+ 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 } })
684
+ }
685
+ if (field.monotonic !== undefined && field.monotonic !== invariant.direction) {
686
+ 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 } })
687
+ }
688
+ for (const pair of options.copyPairs ?? []) {
689
+ for (const [sourceField, targetField] of Object.entries(pair.mapping)) {
690
+ if (pair.targetEntity + '.' + targetField === invariant.entity + '.' + invariant.field) {
691
+ const source = fieldByPath(model, pair.sourceEntity, sourceField)
692
+ if (source?.monotonic !== undefined && source.monotonic !== invariant.direction) {
693
+ 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 } })
694
+ }
695
+ }
696
+ }
697
+ }
698
+ }
699
+ return checkResult('DA9', 'Data Monotonic', findings, findings.length === 0 ? 'Monotonic data constraints are satisfied' : 'Data monotonic findings: ' + findings.length)
700
+ }
701
+
702
+ function DA10_dataSequence(model: DataModelV1, options: DataVerificationOptions): CheckResult {
703
+ const findings: Finding[] = []
704
+ const registry = dataStepRegistry(options)
705
+ for (const invariant of model.invariants ?? []) {
706
+ if (invariant.kind !== 'sequence') continue
707
+ for (const step of invariant.steps) {
708
+ if (!registry.has(step)) {
709
+ findings.push({ code: 'DA10_SEQUENCE_STEP_MISSING', severity: 'error', message: 'Sequence invariant "' + invariant.id + '" references missing step ' + step, evidence: { invariant, step } })
710
+ }
711
+ }
712
+ }
713
+ return checkResult('DA10', 'Data Sequence', findings, findings.length === 0 ? 'Data sequence steps exist' : 'Data sequence findings: ' + findings.length)
714
+ }
715
+
716
+ function DA11_dataLeadsTo(model: DataModelV1, options: DataVerificationOptions): CheckResult {
717
+ const findings: Finding[] = []
718
+ for (const invariant of model.invariants ?? []) {
719
+ if (invariant.kind !== 'leads-to') continue
720
+ const field = fieldByPath(model, invariant.entity, invariant.field)
721
+ if (field === undefined) {
722
+ 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 } })
723
+ continue
724
+ }
725
+ if (field.enum !== undefined && field.enum.length > 0) {
726
+ 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 } })
727
+ 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 } })
728
+ }
729
+ }
730
+ return checkResult('DA11', 'Data Leads-To', findings, findings.length === 0 ? 'Data leads-to constraints are structurally valid' : 'Data leads-to findings: ' + findings.length)
731
+ }
732
+
733
+ function DA12_dataAtomicity(model: DataModelV1, options: DataVerificationOptions): CheckResult {
734
+ const findings: Finding[] = []
735
+ const registry = dataStepRegistry(options)
736
+ for (const invariant of model.invariants ?? []) {
737
+ if (invariant.kind !== 'atomicity') continue
738
+ for (const step of invariant.steps) {
739
+ const entry = registry.get(step)
740
+ if (entry === undefined) {
741
+ findings.push({ code: 'DA12_ATOMIC_STEP_MISSING', severity: 'error', message: 'Atomicity invariant "' + invariant.id + '" references missing step ' + step, evidence: { invariant, step } })
742
+ continue
743
+ }
744
+ if (entry.kind === 'migration' && (entry.transform === 'drop' || entry.transform === 'split' || entry.transform === 'merge')) {
745
+ findings.push({ code: 'DA12_NON_ATOMIC_TRANSFORM', severity: 'warning', message: 'Atomic step ' + step + ' uses non-atomic transform ' + entry.transform, evidence: { invariant, step } })
746
+ }
747
+ if (entry.kind === 'copy' && entry.copyPair !== undefined) {
748
+ const hasBackup = (options.backupPairs ?? []).some((pair) => pair.sourceEntity === entry.copyPair!.sourceEntity && pair.targetEntity === entry.copyPair!.targetEntity)
749
+ 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 } })
750
+ }
751
+ }
752
+ }
753
+ return checkResult('DA12', 'Data Atomicity', findings, findings.length === 0 ? 'Data atomicity constraints are satisfied' : 'Data atomicity findings: ' + findings.length)
754
+ }
755
+
756
+ function DD1_dataBehaviorPreservation(before: DataModelV1, after: DataModelV1, options: DataVerificationOptions): CheckResult {
757
+ const findings: Finding[] = []
758
+ const afterFields = entityFieldMap(after)
759
+ const mapping = options.fieldMapping ?? {}
760
+ const migrationFrom = new Set((options.migrationMappings ?? []).map((mapping) => mapping.from))
761
+ for (const entity of before.entities) {
762
+ for (const field of entity.fields) {
763
+ const path = entity.name + '.' + field.name
764
+ const mapped = mapFieldPath(mapping, path)
765
+ const afterField = afterFields.get(mapped)
766
+ if (afterField === undefined) {
767
+ if (!migrationFrom.has(path)) {
768
+ 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 } })
769
+ }
770
+ continue
771
+ }
772
+ if (field.required === true && afterField.required !== true && !migrationFrom.has(path)) {
773
+ findings.push({ code: 'DD1_REQUIRED_BECAME_OPTIONAL', severity: 'warning', message: 'BEFORE required field ' + path + ' is optional in AFTER', evidence: { beforeField: path, afterField: mapped } })
774
+ }
775
+ }
776
+ }
777
+ return checkResult('DD1', 'Data Behavior Preservation', findings, findings.length === 0 ? 'BEFORE data behavior is preserved in AFTER' : 'Data behavior findings: ' + findings.length)
778
+ }
779
+
780
+ function DD2_dataInvariantContinuity(before: DataModelV1, after: DataModelV1, options: DataVerificationOptions): CheckResult {
781
+ const findings: Finding[] = []
782
+ const afterEntities = new Set(after.entities.map((entity) => entity.name))
783
+ const afterFields = entityFieldMap(after)
784
+ const mapping = options.fieldMapping ?? {}
785
+ for (const invariant of before.invariants ?? []) {
786
+ if (invariant.kind === 'field-required' || invariant.kind === 'range' || invariant.kind === 'no-orphan') {
787
+ const mappedField = mapFieldPath(mapping, invariant.entity + '.' + invariant.field)
788
+ const [entityName, fieldName] = mappedField.split('.')
789
+ if (!afterEntities.has(entityName) || !afterFields.has(mappedField)) {
790
+ findings.push({ code: 'DD2_INVARIANT_FIELD_MISSING', severity: 'error', message: 'BEFORE invariant "' + invariant.id + '" references ' + mappedField + ', missing in AFTER', evidence: { invariant, mappedField } })
791
+ }
792
+ } else if (invariant.kind === 'unique') {
793
+ for (const field of invariant.fields) {
794
+ const mappedField = mapFieldPath(mapping, invariant.entity + '.' + field)
795
+ if (!afterFields.has(mappedField)) {
796
+ findings.push({ code: 'DD2_INVARIANT_FIELD_MISSING', severity: 'error', message: 'BEFORE unique invariant "' + invariant.id + '" references ' + mappedField + ', missing in AFTER', evidence: { invariant, mappedField } })
797
+ }
798
+ }
799
+ } else if (invariant.kind === 'referential-integrity') {
800
+ const mappedField = mapFieldPath(mapping, invariant.entity + '.' + invariant.field)
801
+ if (!afterFields.has(mappedField)) {
802
+ findings.push({ code: 'DD2_INVARIANT_FIELD_MISSING', severity: 'error', message: 'BEFORE referential invariant "' + invariant.id + '" references ' + mappedField + ', missing in AFTER', evidence: { invariant, mappedField } })
803
+ }
804
+ if (!afterEntities.has(invariant.refEntity)) {
805
+ findings.push({ code: 'DD2_INVARIANT_ENTITY_MISSING', severity: 'error', message: 'BEFORE referential invariant "' + invariant.id + '" references missing entity ' + invariant.refEntity, evidence: { invariant } })
806
+ }
807
+ } else if (invariant.kind === 'count-equal') {
808
+ 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 } })
809
+ 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 } })
810
+ } else if (invariant.kind === 'field-equal') {
811
+ const sourceMapped = mapFieldPath(mapping, invariant.sourceEntity + '.' + invariant.sourceField)
812
+ const targetMapped = mapFieldPath(mapping, invariant.targetEntity + '.' + invariant.targetField)
813
+ 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 } })
814
+ 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 } })
815
+ }
816
+ }
817
+ return checkResult('DD2', 'Data Invariant Continuity', findings, findings.length === 0 ? 'All BEFORE data invariants continue to hold structurally' : 'Data invariant continuity findings: ' + findings.length)
818
+ }
819
+
820
+ function DD3_deltaSummary(before: DataModelV1, after: DataModelV1, options: DataVerificationOptions): CheckResult {
821
+ const findings: Finding[] = []
822
+ const afterEntities = new Set(after.entities.map((entity) => entity.name))
823
+ const beforeEntities = new Set(before.entities.map((entity) => entity.name))
824
+ const removedEntities = before.entities.filter((entity) => !afterEntities.has(entity.name)).map((entity) => entity.name)
825
+ const addedEntities = after.entities.filter((entity) => !beforeEntities.has(entity.name)).map((entity) => entity.name)
826
+ const mapping = options.fieldMapping ?? {}
827
+ const beforePaths = new Set(allFieldPaths(before))
828
+ const afterPaths = new Set(allFieldPaths(after))
829
+ const removedFields: string[] = []
830
+ const addedFields: string[] = []
831
+ for (const path of beforePaths) {
832
+ const mapped = mapFieldPath(mapping, path)
833
+ if (!afterPaths.has(mapped)) removedFields.push(path)
834
+ }
835
+ for (const path of afterPaths) {
836
+ let found = false
837
+ for (const beforePath of beforePaths) {
838
+ if (mapFieldPath(mapping, beforePath) === path) {
839
+ found = true
840
+ break
841
+ }
842
+ }
843
+ if (!found) addedFields.push(path)
844
+ }
845
+ for (const entity of removedEntities) findings.push({ code: 'DD3_REMOVED_ENTITY', severity: 'warning', message: 'BEFORE entity ' + entity + ' is not present in AFTER', evidence: { entity } })
846
+ for (const field of removedFields) findings.push({ code: 'DD3_REMOVED_FIELD', severity: 'warning', message: 'BEFORE field ' + field + ' is not present in AFTER', evidence: { field } })
847
+ const detail = 'Delta: +' + addedEntities.length + ' entities, -' + removedEntities.length + ' entities, +' + addedFields.length + ' fields, -' + removedFields.length + ' fields'
848
+ return checkResult('DD3', 'Delta Summary', findings, detail)
849
+ }
850
+
851
+ function DD4_breakingChangeRegression(before: DataModelV1, after: DataModelV1, options: DataVerificationOptions): CheckResult {
852
+ const findings: Finding[] = []
853
+ const afterFields = entityFieldMap(after)
854
+ const mapping = options.fieldMapping ?? {}
855
+ const migrationFrom = new Set((options.migrationMappings ?? []).map((mapping) => mapping.from))
856
+ for (const entity of before.entities) {
857
+ for (const field of entity.fields) {
858
+ const path = entity.name + '.' + field.name
859
+ const mapped = mapFieldPath(mapping, path)
860
+ const afterField = afterFields.get(mapped)
861
+ if (afterField === undefined) {
862
+ if (field.required === true && !migrationFrom.has(path)) {
863
+ findings.push({ code: 'DD4_REQUIRED_FIELD_REMOVED', severity: 'error', message: 'Required field ' + path + ' was removed without migration mapping', evidence: { beforeField: path } })
864
+ }
865
+ continue
866
+ }
867
+ if (field.required !== true && afterField.required === true && !migrationFrom.has(path)) {
868
+ findings.push({ code: 'DD4_OPTIONAL_BECAME_REQUIRED', severity: 'warning', message: 'Optional field ' + path + ' became required in AFTER', evidence: { beforeField: path, afterField: mapped } })
869
+ }
870
+ if (field.nullable !== false && afterField.nullable === false && !migrationFrom.has(path)) {
871
+ findings.push({ code: 'DD4_NULLABLE_BECAME_NOT_NULL', severity: 'warning', message: 'Nullable field ' + path + ' became non-nullable in AFTER', evidence: { beforeField: path, afterField: mapped } })
872
+ }
873
+ if (field.type !== afterField.type && !migrationFrom.has(path)) {
874
+ 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 } })
875
+ }
876
+ }
877
+ }
878
+ return checkResult('DD4', 'Breaking Change Regression', findings, findings.length === 0 ? 'No new breaking changes detected' : 'Breaking change findings: ' + findings.length)
879
+ }
880
+
881
+ function buildDataComparisonSummary(before: DataModelV1, after: DataModelV1, options: DataVerificationOptions): DataComparisonSummary {
882
+ const afterEntities = new Set(after.entities.map((entity) => entity.name))
883
+ const beforeEntities = new Set(before.entities.map((entity) => entity.name))
884
+ const addedEntities = after.entities.filter((entity) => !beforeEntities.has(entity.name)).map((entity) => entity.name).sort()
885
+ const removedEntities = before.entities.filter((entity) => !afterEntities.has(entity.name)).map((entity) => entity.name).sort()
886
+ const mapping = options.fieldMapping ?? {}
887
+ const beforePaths = allFieldPaths(before)
888
+ const afterPaths = allFieldPaths(after)
889
+ const afterPathSet = new Set(afterPaths)
890
+ const removedFields = beforePaths.filter((path) => !afterPathSet.has(mapFieldPath(mapping, path))).sort()
891
+ const addedFields = afterPaths.filter((path) => !beforePaths.some((beforePath) => mapFieldPath(mapping, beforePath) === path)).sort()
892
+ const beforeRelationships = (before.relationships ?? []).map((relationship) => relationship.name ?? relationship.fromEntity + '.' + relationship.fromField + '->' + relationship.toEntity)
893
+ const afterRelationships = (after.relationships ?? []).map((relationship) => relationship.name ?? relationship.fromEntity + '.' + relationship.fromField + '->' + relationship.toEntity)
894
+ const removedRelationships = beforeRelationships.filter((name) => !afterRelationships.includes(name)).sort()
895
+ const addedRelationships = afterRelationships.filter((name) => !beforeRelationships.includes(name)).sort()
896
+ return {
897
+ beforeModelHash: dataModelHash(before),
898
+ afterModelHash: dataModelHash(after),
899
+ fieldMapping: mapping,
900
+ beforeEntities: before.entities.length,
901
+ beforeFields: beforePaths.length,
902
+ afterEntities: after.entities.length,
903
+ afterFields: afterPaths.length,
904
+ addedEntities,
905
+ removedEntities,
906
+ addedFields,
907
+ removedFields,
908
+ addedRelationships,
909
+ removedRelationships,
910
+ }
911
+ }
912
+
913
+ export function runDataVerification(input: unknown, options: DataVerificationOptions = {}): DataVerificationReport {
914
+ const validation = validateDataModel(input)
915
+ if (!validation.ok) {
916
+ return {
917
+ ok: false,
918
+ schemaVersion: 1,
919
+ modelHash: '',
920
+ summary: { entities: 0, fields: 0, errors: validation.errors.length, warnings: 0, checksRun: 0 },
921
+ checks: [{
922
+ id: 'DATA_MODEL',
923
+ name: 'Data Model Validation',
924
+ status: 'fail',
925
+ detail: 'Data model schema validation failed: ' + validation.errors.length + ' errors',
926
+ findings: validation.errors.map((message) => ({ code: 'DATA_MODEL_INVALID', severity: 'error', message })),
927
+ }],
928
+ }
929
+ }
930
+ const model = validation.model
931
+ const checks: CheckResult[] = [
932
+ DS1_schemaWellFormed(model),
933
+ DS2_requiredCompleteness(model, options),
934
+ DS3_relationshipIntegrity(model),
935
+ DS4_typeNullabilityConsistency(model),
936
+ DA1_nullEmptyInjection(model),
937
+ DA2_boundaryBlast(model),
938
+ DA3_uniquenessViolation(model),
939
+ DA4_referentialIntegrityViolation(model),
940
+ DA5_migrationCoverage(model, options),
941
+ DA6_copyConsistency(model, options),
942
+ DA7_rollbackBackupSymmetry(model, options),
943
+ DA8_idempotentConstraints(model, options),
944
+ DA9_dataMonotonic(model, options),
945
+ DA10_dataSequence(model, options),
946
+ DA11_dataLeadsTo(model, options),
947
+ DA12_dataAtomicity(model, options),
948
+ ]
949
+ let comparison: DataComparisonSummary | undefined
950
+ if (options.beforeModel !== undefined) {
951
+ const beforeValidation = validateDataModel(options.beforeModel)
952
+ if (!beforeValidation.ok) {
953
+ return {
954
+ ok: false,
955
+ schemaVersion: 1,
956
+ modelHash: dataModelHash(model),
957
+ summary: {
958
+ entities: model.entities.length,
959
+ fields: allFieldPaths(model).length,
960
+ errors: beforeValidation.errors.length,
961
+ warnings: 0,
962
+ checksRun: checks.length + 1,
963
+ },
964
+ checks: [
965
+ ...checks,
966
+ {
967
+ id: 'BEFORE_DATA_MODEL',
968
+ name: 'Before Data Model Validation',
969
+ status: 'fail',
970
+ detail: 'Before data model schema validation failed: ' + beforeValidation.errors.length + ' errors',
971
+ findings: beforeValidation.errors.map((message) => ({ code: 'BEFORE_DATA_MODEL_INVALID', severity: 'error', message })),
972
+ },
973
+ ],
974
+ }
975
+ }
976
+ const before = beforeValidation.model
977
+ checks.push(
978
+ DD1_dataBehaviorPreservation(before, model, options),
979
+ DD2_dataInvariantContinuity(before, model, options),
980
+ DD3_deltaSummary(before, model, options),
981
+ DD4_breakingChangeRegression(before, model, options),
982
+ )
983
+ comparison = buildDataComparisonSummary(before, model, options)
984
+ }
985
+ const errors = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'error').length, 0)
986
+ const warnings = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'warning').length, 0)
987
+ return {
988
+ ok: true,
989
+ schemaVersion: 1,
990
+ modelHash: dataModelHash(model),
991
+ summary: {
992
+ entities: model.entities.length,
993
+ fields: allFieldPaths(model).length,
994
+ errors,
995
+ warnings,
996
+ checksRun: checks.length,
997
+ },
998
+ checks,
999
+ ...(comparison === undefined ? {} : { comparison }),
1000
+ }
1001
+ }