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,528 @@
1
+ #!/usr/bin/env python3
2
+ """Data Model Verification Harness — generic template for logicprobe-datamodel.
3
+
4
+ Usage:
5
+ python3 data-model-harness.py --model model.json [--before-model before.json]
6
+ [--field-mapping mapping.json] [--copy-pairs copies.json]
7
+ [--migration-mappings migrations.json] [--backup-pairs backups.json]
8
+
9
+ Outputs a structured DS/DA/DD verification report.
10
+ """
11
+ import argparse
12
+ import json
13
+ import sys
14
+ from collections import defaultdict
15
+
16
+ KNOWN_TYPES = {
17
+ 'string', 'integer', 'number', 'boolean', 'uuid', 'date', 'datetime',
18
+ 'timestamp', 'json', 'enum', 'array', 'object', 'binary',
19
+ }
20
+
21
+
22
+ def load_json(path):
23
+ with open(path, encoding='utf-8') as handle:
24
+ return json.load(handle)
25
+
26
+
27
+ def entity_by_name(model, name):
28
+ for entity in model.get('entities', []):
29
+ if entity.get('name') == name:
30
+ return entity
31
+ return None
32
+
33
+
34
+ def field_by_path(model, entity_name, field_name):
35
+ entity = entity_by_name(model, entity_name)
36
+ if entity is None:
37
+ return None
38
+ for field in entity.get('fields', []):
39
+ if field.get('name') == field_name:
40
+ return field
41
+ return None
42
+
43
+
44
+ def all_field_paths(model):
45
+ result = []
46
+ for entity in model.get('entities', []):
47
+ for field in entity.get('fields', []):
48
+ result.append(entity.get('name') + '.' + field.get('name'))
49
+ return result
50
+
51
+
52
+ def map_field(mapping, path):
53
+ return mapping.get(path, path) if mapping else path
54
+
55
+
56
+ def check_result(name, findings, detail=None):
57
+ errors = sum(1 for finding in findings if finding.get('severity') == 'error')
58
+ warnings = sum(1 for finding in findings if finding.get('severity') == 'warning')
59
+ if detail is None:
60
+ detail = f"{len(findings)} findings" if findings else "No findings"
61
+ return {
62
+ 'id': name,
63
+ 'status': 'FAIL' if findings else 'PASS',
64
+ 'findings': findings,
65
+ 'detail': detail,
66
+ 'errors': errors,
67
+ 'warnings': warnings,
68
+ }
69
+
70
+
71
+ def ds1_schema_well_formed(model):
72
+ findings = []
73
+ entity_names = set()
74
+ for entity in model.get('entities', []):
75
+ name = entity.get('name')
76
+ if name in entity_names:
77
+ findings.append({'code': 'DS1_DUPLICATE_ENTITY', 'severity': 'error', 'message': f'Duplicate entity {name}', 'evidence': {'entity': name}})
78
+ entity_names.add(name)
79
+ field_names = set()
80
+ for field in entity.get('fields', []):
81
+ fname = field.get('name')
82
+ if fname in field_names:
83
+ findings.append({'code': 'DS1_DUPLICATE_FIELD', 'severity': 'error', 'message': f'Duplicate field {name}.{fname}', 'evidence': {'entity': name, 'field': fname}})
84
+ field_names.add(fname)
85
+ if not field.get('type'):
86
+ findings.append({'code': 'DS1_EMPTY_TYPE', 'severity': 'error', 'message': f'Field {name}.{fname} has empty type', 'evidence': {'entity': name, 'field': fname}})
87
+ for key in entity.get('primaryKey', []):
88
+ if key not in field_names:
89
+ findings.append({'code': 'DS1_PRIMARY_KEY_MISSING', 'severity': 'error', 'message': f'Primary key {key} missing in {name}', 'evidence': {'entity': name, 'field': key}})
90
+ for key_set in entity.get('uniqueKeys', []):
91
+ for key in key_set:
92
+ if key not in field_names:
93
+ findings.append({'code': 'DS1_UNIQUE_KEY_MISSING', 'severity': 'error', 'message': f'Unique key {key} missing in {name}', 'evidence': {'entity': name, 'field': key}})
94
+ return check_result('DS1', findings, 'Schema well-formedness')
95
+
96
+
97
+ def ds2_required_completeness(model, options):
98
+ findings = []
99
+ migration_from = {m.get('from') for m in options.get('migrationMappings', [])}
100
+ copy_targets = set()
101
+ for pair in options.get('copyPairs', []):
102
+ for target in pair.get('mapping', {}).values():
103
+ copy_targets.add(pair.get('targetEntity') + '.' + target)
104
+ before_paths = set()
105
+ if options.get('beforeModel') is not None:
106
+ mapping = options.get('fieldMapping', {})
107
+ before_paths = {map_field(mapping, path) for path in all_field_paths(options['beforeModel'])}
108
+ for entity in model.get('entities', []):
109
+ for field in entity.get('fields', []):
110
+ if field.get('required') is not True:
111
+ continue
112
+ if field.get('name') in entity.get('primaryKey', []) and field.get('nullable') is True:
113
+ findings.append({'code': 'DS2_NULLABLE_PRIMARY_KEY', 'severity': 'error', 'message': f'Primary key {entity.get("name")}.{field.get("name")} must not be nullable', 'evidence': {'entity': entity.get('name'), 'field': field.get('name')}})
114
+ if options.get('beforeModel') is not None:
115
+ path = entity.get('name') + '.' + field.get('name')
116
+ if path not in before_paths and path not in migration_from and path not in copy_targets:
117
+ findings.append({'code': 'DS2_REQUIRED_FIELD_UNSOURCED', 'severity': 'warning', 'message': f'Required field {path} has no migration mapping or copy target', 'evidence': {'entity': entity.get('name'), 'field': field.get('name')}})
118
+ return check_result('DS2', findings, 'Required-field completeness')
119
+
120
+
121
+ def ds3_relationship_integrity(model):
122
+ findings = []
123
+ for rel in model.get('relationships', []):
124
+ from_entity = entity_by_name(model, rel.get('fromEntity'))
125
+ to_entity = entity_by_name(model, rel.get('toEntity'))
126
+ if from_entity is None:
127
+ findings.append({'code': 'DS3_FROM_ENTITY_MISSING', 'severity': 'error', 'message': f"Unknown fromEntity {rel.get('fromEntity')}", 'evidence': {'relationship': rel}})
128
+ continue
129
+ if to_entity is None:
130
+ findings.append({'code': 'DS3_TO_ENTITY_MISSING', 'severity': 'error', 'message': f"Unknown toEntity {rel.get('toEntity')}", 'evidence': {'relationship': rel}})
131
+ continue
132
+ if not any(f.get('name') == rel.get('fromField') for f in from_entity.get('fields', [])):
133
+ findings.append({'code': 'DS3_FROM_FIELD_MISSING', 'severity': 'error', 'message': f"Field {rel.get('fromField')} missing in {rel.get('fromEntity')}", 'evidence': {'relationship': rel}})
134
+ if rel.get('toField') is not None and not any(f.get('name') == rel.get('toField') for f in to_entity.get('fields', [])):
135
+ findings.append({'code': 'DS3_TO_FIELD_MISSING', 'severity': 'error', 'message': f"Field {rel.get('toField')} missing in {rel.get('toEntity')}", 'evidence': {'relationship': rel}})
136
+ return check_result('DS3', findings, 'Relationship integrity')
137
+
138
+
139
+ def ds4_type_nullability_consistency(model):
140
+ findings = []
141
+ for entity in model.get('entities', []):
142
+ for field in entity.get('fields', []):
143
+ name = entity.get('name') + '.' + field.get('name')
144
+ if field.get('required') is True and field.get('nullable') is True:
145
+ findings.append({'code': 'DS4_REQUIRED_NULLABLE', 'severity': 'error', 'message': f'{name} is both required and nullable', 'evidence': {'entity': entity.get('name'), 'field': field.get('name')}})
146
+ if field.get('unique') is True and field.get('nullable') is True:
147
+ findings.append({'code': 'DS4_UNIQUE_NULLABLE', 'severity': 'warning', 'message': f'{name} is unique and nullable', 'evidence': {'entity': entity.get('name'), 'field': field.get('name')}})
148
+ if isinstance(field.get('min'), (int, float)) and isinstance(field.get('max'), (int, float)) and field['min'] > field['max']:
149
+ findings.append({'code': 'DS4_MIN_GT_MAX', 'severity': 'error', 'message': f'{name} has min > max', 'evidence': {'entity': entity.get('name'), 'field': field.get('name')}})
150
+ if isinstance(field.get('minLength'), (int, float)) and isinstance(field.get('maxLength'), (int, float)) and field['minLength'] > field['maxLength']:
151
+ findings.append({'code': 'DS4_MIN_LENGTH_GT_MAX_LENGTH', 'severity': 'error', 'message': f'{name} has minLength > maxLength', 'evidence': {'entity': entity.get('name'), 'field': field.get('name')}})
152
+ return check_result('DS4', findings, 'Type/nullability consistency')
153
+
154
+
155
+ def da1_null_empty_injection(model):
156
+ findings = []
157
+ for entity in model.get('entities', []):
158
+ for field in entity.get('fields', []):
159
+ name = entity.get('name') + '.' + field.get('name')
160
+ if field.get('required') is True and field.get('nullable') is True:
161
+ findings.append({'code': 'DA1_NULL_ACCEPTED_ON_REQUIRED', 'severity': 'error', 'message': f'Null can be injected into required field {name}', 'evidence': {'entity': entity.get('name'), 'field': field.get('name')}})
162
+ if field.get('required') is True and field.get('type') == 'string' and field.get('minLength') == 0:
163
+ findings.append({'code': 'DA1_EMPTY_STRING_ALLOWED', 'severity': 'warning', 'message': f'Required string field {name} allows empty string', 'evidence': {'entity': entity.get('name'), 'field': field.get('name')}})
164
+ return check_result('DA1', findings, 'Null/empty injection')
165
+
166
+
167
+ def da2_boundary_blast(model):
168
+ findings = []
169
+ for entity in model.get('entities', []):
170
+ for field in entity.get('fields', []):
171
+ name = entity.get('name') + '.' + field.get('name')
172
+ if isinstance(field.get('default'), (int, float)):
173
+ if field.get('min') is not None and field['default'] < field['min']:
174
+ findings.append({'code': 'DA2_DEFAULT_BELOW_MIN', 'severity': 'error', 'message': f'Default below min for {name}', 'evidence': {'entity': entity.get('name'), 'field': field.get('name')}})
175
+ if field.get('max') is not None and field['default'] > field['max']:
176
+ findings.append({'code': 'DA2_DEFAULT_ABOVE_MAX', 'severity': 'error', 'message': f'Default above max for {name}', 'evidence': {'entity': entity.get('name'), 'field': field.get('name')}})
177
+ for check in model.get('boundaryChecks', []):
178
+ field = field_by_path(model, check.get('entity'), check.get('field'))
179
+ if field is None:
180
+ findings.append({'code': 'DA2_BOUNDARY_FIELD_MISSING', 'severity': 'error', 'message': f"Unknown boundary field {check.get('entity')}.{check.get('field')}", 'evidence': check})
181
+ continue
182
+ for value in check.get('values', []):
183
+ if isinstance(value, (int, float)):
184
+ if field.get('min') is not None and value < field['min']:
185
+ findings.append({'code': 'DA2_BOUNDARY_BELOW_MIN', 'severity': 'warning', 'message': f'Boundary below min for {check.get("entity")}.{check.get("field")}', 'evidence': check})
186
+ if field.get('max') is not None and value > field['max']:
187
+ findings.append({'code': 'DA2_BOUNDARY_ABOVE_MAX', 'severity': 'warning', 'message': f'Boundary above max for {check.get("entity")}.{check.get("field")}', 'evidence': check})
188
+ return check_result('DA2', findings, 'Boundary blast')
189
+
190
+
191
+ def da3_uniqueness_violation(model):
192
+ findings = []
193
+ for entity in model.get('entities', []):
194
+ for key in entity.get('primaryKey', []):
195
+ field = field_by_path(model, entity.get('name'), key)
196
+ if field is not None and field.get('nullable') is True:
197
+ findings.append({'code': 'DA3_NULLABLE_PRIMARY_KEY', 'severity': 'error', 'message': f'Primary key {entity.get("name")}.{key} is nullable', 'evidence': {'entity': entity.get('name'), 'field': key}})
198
+ for key_set in entity.get('uniqueKeys', []):
199
+ for key in key_set:
200
+ field = field_by_path(model, entity.get('name'), key)
201
+ if field is not None and field.get('nullable') is True:
202
+ findings.append({'code': 'DA3_NULLABLE_UNIQUE_KEY', 'severity': 'warning', 'message': f'Unique key {entity.get("name")}.{key} is nullable', 'evidence': {'entity': entity.get('name'), 'field': key}})
203
+ return check_result('DA3', findings, 'Uniqueness violation')
204
+
205
+
206
+ def da4_referential_integrity_violation(model):
207
+ findings = []
208
+ for rel in model.get('relationships', []):
209
+ from_field = field_by_path(model, rel.get('fromEntity'), rel.get('fromField'))
210
+ to_field = None
211
+ if rel.get('toField') is not None:
212
+ to_field = field_by_path(model, rel.get('toEntity'), rel.get('toField'))
213
+ if from_field is not None and to_field is not None and from_field.get('type') != to_field.get('type'):
214
+ findings.append({'code': 'DA4_REF_TYPE_MISMATCH', 'severity': 'warning', 'message': f"Relationship type mismatch {rel.get('fromEntity')}.{rel.get('fromField')} -> {rel.get('toEntity')}.{rel.get('toField')}", 'evidence': {'relationship': rel}})
215
+ return check_result('DA4', findings, 'Referential integrity violation')
216
+
217
+
218
+ def da5_migration_coverage(model, options):
219
+ findings = []
220
+ before = options.get('beforeModel')
221
+ if before is None:
222
+ return check_result('DA5', [], 'No beforeModel supplied — skipped')
223
+ after_paths = set(all_field_paths(model))
224
+ before_paths = all_field_paths(before)
225
+ mapping = options.get('fieldMapping', {})
226
+ migration_from = {m.get('from') for m in options.get('migrationMappings', [])}
227
+ for path in before_paths:
228
+ mapped = map_field(mapping, path)
229
+ if mapped not in after_paths and path not in migration_from:
230
+ findings.append({'code': 'DA5_FIELD_NOT_MIGRATED', 'severity': 'error', 'message': f'BEFORE field {path} is missing in AFTER and has no migration mapping', 'evidence': {'beforeField': path, 'afterField': mapped}})
231
+ return check_result('DA5', findings, 'Migration coverage')
232
+
233
+
234
+ def da6_copy_consistency(model, options):
235
+ findings = []
236
+ for pair in options.get('copyPairs', []):
237
+ target_entity = entity_by_name(model, pair.get('targetEntity'))
238
+ if target_entity is None:
239
+ findings.append({'code': 'DA6_TARGET_ENTITY_MISSING', 'severity': 'error', 'message': f"Unknown target entity {pair.get('targetEntity')}", 'evidence': {'pair': pair}})
240
+ continue
241
+ if entity_by_name(model, pair.get('sourceEntity')) is None:
242
+ findings.append({'code': 'DA6_SOURCE_ENTITY_MISSING', 'severity': 'error', 'message': f"Unknown source entity {pair.get('sourceEntity')}", 'evidence': {'pair': pair}})
243
+ mapped_targets = {pair.get('targetEntity') + '.' + target for target in pair.get('mapping', {}).values()}
244
+ for field in target_entity.get('fields', []):
245
+ if field.get('required') is True and (pair.get('targetEntity') + '.' + field.get('name')) not in mapped_targets:
246
+ findings.append({'code': 'DA6_REQUIRED_TARGET_UNMAPPED', 'severity': 'error', 'message': f"Copy pair {pair.get('id')} does not map required target field {pair.get('targetEntity')}.{field.get('name')}", 'evidence': {'pair': pair, 'field': field.get('name')}})
247
+ return check_result('DA6', findings, 'Copy consistency')
248
+
249
+
250
+ def da7_rollback_backup_symmetry(model, options):
251
+ findings = []
252
+ backup_keys = {pair.get('sourceEntity') + '|' + pair.get('targetEntity') for pair in options.get('backupPairs', [])}
253
+ for pair in options.get('copyPairs', []):
254
+ key = pair.get('sourceEntity') + '|' + pair.get('targetEntity')
255
+ if key not in backup_keys:
256
+ findings.append({'code': 'DA7_NO_BACKUP_PAIR', 'severity': 'warning', 'message': f"Copy pair {pair.get('id')} has no matching backup/restore pair", 'evidence': {'pair': pair}})
257
+ return check_result('DA7', findings, 'Rollback/backup symmetry')
258
+
259
+
260
+ def da8_idempotent_constraints(model, options):
261
+ findings = []
262
+ copy_pairs = options.get('copyPairs', [])
263
+ migration_mappings = options.get('migrationMappings', [])
264
+ for invariant in model.get('invariants', []):
265
+ kind = invariant.get('kind')
266
+ if kind == 'idempotent-copy':
267
+ pair = next((p for p in copy_pairs if p.get('sourceEntity') == invariant.get('sourceEntity') and p.get('targetEntity') == invariant.get('targetEntity')), None)
268
+ if pair is None:
269
+ findings.append({'code': 'DA8_COPY_PAIR_MISSING', 'severity': 'error', 'message': f"Idempotent-copy invariant {invariant.get('id')} has no matching copy pair", 'evidence': {'invariant': invariant}})
270
+ elif kind == 'idempotent-migration':
271
+ mapping = next((m for m in migration_mappings if m.get('from') == invariant.get('from') and m.get('to') == invariant.get('to')), None)
272
+ if mapping is None:
273
+ findings.append({'code': 'DA8_MIGRATION_MAPPING_MISSING', 'severity': 'error', 'message': f"Idempotent-migration invariant {invariant.get('id')} has no matching migration mapping", 'evidence': {'invariant': invariant}})
274
+ elif mapping.get('transform') in ('split', 'merge', 'drop'):
275
+ findings.append({'code': 'DA8_NON_IDEMPOTENT_TRANSFORM', 'severity': 'warning', 'message': f"Migration mapping {invariant.get('from')} -> {invariant.get('to')} uses non-idempotent transform {mapping.get('transform')}", 'evidence': {'invariant': invariant, 'mapping': mapping}})
276
+ return check_result('DA8', findings, 'Idempotent constraints')
277
+
278
+ def data_step_registry(options):
279
+ registry = {}
280
+ for pair in options.get('copyPairs', []):
281
+ registry[pair.get('id')] = {'kind': 'copy', 'copyPair': pair}
282
+ for mapping in options.get('migrationMappings', []):
283
+ step_id = mapping.get('id') or (mapping.get('from') + '->' + mapping.get('to'))
284
+ registry[step_id] = {'kind': 'migration', 'transform': mapping.get('transform')}
285
+ return registry
286
+
287
+ MONOTONIC_DATA_TYPES = {'integer', 'number', 'date', 'datetime', 'timestamp'}
288
+
289
+ def da9_data_monotonic(model, options):
290
+ findings = []
291
+ for invariant in model.get('invariants', []):
292
+ if invariant.get('kind') != 'monotonic':
293
+ continue
294
+ field = field_by_path(model, invariant.get('entity'), invariant.get('field'))
295
+ if field is None:
296
+ findings.append({'code': 'DA9_MONOTONIC_FIELD_MISSING', 'severity': 'error', 'message': f"Monotonic invariant {invariant.get('id')} references missing field", 'evidence': {'invariant': invariant}})
297
+ continue
298
+ if field.get('type') not in MONOTONIC_DATA_TYPES:
299
+ findings.append({'code': 'DA9_MONOTONIC_TYPE_UNSUPPORTED', 'severity': 'warning', 'message': f"Monotonic field {invariant.get('entity')}.{invariant.get('field')} has unsupported type {field.get('type')}", 'evidence': {'invariant': invariant}})
300
+ if field.get('monotonic') is not None and field.get('monotonic') != invariant.get('direction'):
301
+ findings.append({'code': 'DA9_MONOTONIC_DIRECTION_MISMATCH', 'severity': 'error', 'message': f"Monotonic direction mismatch for {invariant.get('entity')}.{invariant.get('field')}", 'evidence': {'invariant': invariant}})
302
+ for pair in options.get('copyPairs', []):
303
+ for source_field, target_field in pair.get('mapping', {}).items():
304
+ if pair.get('targetEntity') + '.' + target_field == invariant.get('entity') + '.' + invariant.get('field'):
305
+ source = field_by_path(model, pair.get('sourceEntity'), source_field)
306
+ if source is not None and source.get('monotonic') is not None and source.get('monotonic') != invariant.get('direction'):
307
+ findings.append({'code': 'DA9_MONOTONIC_COPY_OPPOSITE', 'severity': 'warning', 'message': f"Copy {pair.get('id')} maps opposite-monotonic source into monotonic target", 'evidence': {'invariant': invariant, 'pair': pair}})
308
+ return check_result('DA9', findings, 'Data monotonic')
309
+
310
+ def da10_data_sequence(model, options):
311
+ findings = []
312
+ registry = data_step_registry(options)
313
+ for invariant in model.get('invariants', []):
314
+ if invariant.get('kind') != 'sequence':
315
+ continue
316
+ for step in invariant.get('steps', []):
317
+ if step not in registry:
318
+ findings.append({'code': 'DA10_SEQUENCE_STEP_MISSING', 'severity': 'error', 'message': f"Sequence invariant {invariant.get('id')} references missing step {step}", 'evidence': {'invariant': invariant, 'step': step}})
319
+ return check_result('DA10', findings, 'Data sequence')
320
+
321
+ def da11_data_leads_to(model, options):
322
+ findings = []
323
+ for invariant in model.get('invariants', []):
324
+ if invariant.get('kind') != 'leads-to':
325
+ continue
326
+ field = field_by_path(model, invariant.get('entity'), invariant.get('field'))
327
+ if field is None:
328
+ findings.append({'code': 'DA11_LEADS_TO_FIELD_MISSING', 'severity': 'error', 'message': f"Leads-to invariant {invariant.get('id')} references missing field", 'evidence': {'invariant': invariant}})
329
+ continue
330
+ if field.get('enum'):
331
+ if invariant.get('from') not in field['enum']:
332
+ findings.append({'code': 'DA11_FROM_NOT_IN_ENUM', 'severity': 'warning', 'message': f"Leads-to from value {invariant.get('from')} not in enum", 'evidence': {'invariant': invariant}})
333
+ if invariant.get('to') not in field['enum']:
334
+ findings.append({'code': 'DA11_TO_NOT_IN_ENUM', 'severity': 'warning', 'message': f"Leads-to to value {invariant.get('to')} not in enum", 'evidence': {'invariant': invariant}})
335
+ return check_result('DA11', findings, 'Data leads-to')
336
+
337
+ def da12_data_atomicity(model, options):
338
+ findings = []
339
+ registry = data_step_registry(options)
340
+ for invariant in model.get('invariants', []):
341
+ if invariant.get('kind') != 'atomicity':
342
+ continue
343
+ for step in invariant.get('steps', []):
344
+ entry = registry.get(step)
345
+ if entry is None:
346
+ findings.append({'code': 'DA12_ATOMIC_STEP_MISSING', 'severity': 'error', 'message': f"Atomicity invariant {invariant.get('id')} references missing step {step}", 'evidence': {'invariant': invariant, 'step': step}})
347
+ continue
348
+ if entry.get('kind') == 'migration' and entry.get('transform') in ('drop', 'split', 'merge'):
349
+ findings.append({'code': 'DA12_NON_ATOMIC_TRANSFORM', 'severity': 'warning', 'message': f"Atomic step {step} uses non-atomic transform {entry.get('transform')}", 'evidence': {'invariant': invariant, 'step': step}})
350
+ if entry.get('kind') == 'copy' and entry.get('copyPair') is not None:
351
+ pair = entry['copyPair']
352
+ has_backup = any(bp.get('sourceEntity') == pair.get('sourceEntity') and bp.get('targetEntity') == pair.get('targetEntity') for bp in options.get('backupPairs', []))
353
+ if not has_backup:
354
+ findings.append({'code': 'DA12_ATOMIC_COPY_NO_BACKUP', 'severity': 'warning', 'message': f"Atomic copy step {step} has no backup/restore pair", 'evidence': {'invariant': invariant, 'step': step}})
355
+ return check_result('DA12', findings, 'Data atomicity')
356
+
357
+ def dd1_data_behavior_preservation(before, after, options):
358
+ findings = []
359
+ after_fields = {path: True for path in all_field_paths(after)}
360
+ mapping = options.get('fieldMapping', {})
361
+ migration_from = {m.get('from') for m in options.get('migrationMappings', [])}
362
+ for path in all_field_paths(before):
363
+ mapped = map_field(mapping, path)
364
+ if mapped not in after_fields and path not in migration_from:
365
+ findings.append({'code': 'DD1_FIELD_REMOVED', 'severity': 'error', 'message': f'BEFORE field {path} is missing in AFTER and has no migration mapping', 'evidence': {'beforeField': path, 'mapped': mapped}})
366
+ return check_result('DD1', findings, 'Data behavior preservation')
367
+
368
+
369
+ def dd2_data_invariant_continuity(before, after, options):
370
+ findings = []
371
+ after_fields = set(all_field_paths(after))
372
+ after_entities = {e.get('name') for e in after.get('entities', [])}
373
+ mapping = options.get('fieldMapping', {})
374
+ for invariant in before.get('invariants', []):
375
+ kind = invariant.get('kind')
376
+ if kind in ('field-required', 'range', 'no-orphan'):
377
+ mapped = map_field(mapping, invariant.get('entity') + '.' + invariant.get('field'))
378
+ if mapped not in after_fields:
379
+ findings.append({'code': 'DD2_INVARIANT_FIELD_MISSING', 'severity': 'error', 'message': f"Invariant {invariant.get('id')} references missing field {mapped}", 'evidence': {'invariant': invariant, 'mappedField': mapped}})
380
+ elif kind == 'unique':
381
+ for field in invariant.get('fields', []):
382
+ mapped = map_field(mapping, invariant.get('entity') + '.' + field)
383
+ if mapped not in after_fields:
384
+ findings.append({'code': 'DD2_INVARIANT_FIELD_MISSING', 'severity': 'error', 'message': f"Invariant {invariant.get('id')} references missing field {mapped}", 'evidence': {'invariant': invariant, 'mappedField': mapped}})
385
+ elif kind == 'referential-integrity':
386
+ mapped = map_field(mapping, invariant.get('entity') + '.' + invariant.get('field'))
387
+ if mapped not in after_fields:
388
+ findings.append({'code': 'DD2_INVARIANT_FIELD_MISSING', 'severity': 'error', 'message': f"Invariant {invariant.get('id')} references missing field {mapped}", 'evidence': {'invariant': invariant, 'mappedField': mapped}})
389
+ if invariant.get('refEntity') not in after_entities:
390
+ findings.append({'code': 'DD2_INVARIANT_ENTITY_MISSING', 'severity': 'error', 'message': f"Invariant {invariant.get('id')} references missing entity {invariant.get('refEntity')}", 'evidence': {'invariant': invariant}})
391
+ elif kind == 'count-equal':
392
+ if invariant.get('sourceEntity') not in after_entities:
393
+ findings.append({'code': 'DD2_INVARIANT_ENTITY_MISSING', 'severity': 'error', 'message': f"Invariant {invariant.get('id')} references missing source entity {invariant.get('sourceEntity')}", 'evidence': {'invariant': invariant}})
394
+ if invariant.get('targetEntity') not in after_entities:
395
+ findings.append({'code': 'DD2_INVARIANT_ENTITY_MISSING', 'severity': 'error', 'message': f"Invariant {invariant.get('id')} references missing target entity {invariant.get('targetEntity')}", 'evidence': {'invariant': invariant}})
396
+ elif kind == 'field-equal':
397
+ source_mapped = map_field(mapping, invariant.get('sourceEntity') + '.' + invariant.get('sourceField'))
398
+ target_mapped = map_field(mapping, invariant.get('targetEntity') + '.' + invariant.get('targetField'))
399
+ if source_mapped not in after_fields:
400
+ findings.append({'code': 'DD2_INVARIANT_FIELD_MISSING', 'severity': 'error', 'message': f"Invariant {invariant.get('id')} references missing source field {source_mapped}", 'evidence': {'invariant': invariant, 'sourceMapped': source_mapped}})
401
+ if target_mapped not in after_fields:
402
+ findings.append({'code': 'DD2_INVARIANT_FIELD_MISSING', 'severity': 'error', 'message': f"Invariant {invariant.get('id')} references missing target field {target_mapped}", 'evidence': {'invariant': invariant, 'targetMapped': target_mapped}})
403
+ return check_result('DD2', findings, 'Data invariant continuity')
404
+
405
+
406
+ def dd3_delta_summary(before, after, options):
407
+ findings = []
408
+ before_entities = {e.get('name') for e in before.get('entities', [])}
409
+ after_entities = {e.get('name') for e in after.get('entities', [])}
410
+ removed_entities = sorted(before_entities - after_entities)
411
+ added_entities = sorted(after_entities - before_entities)
412
+ mapping = options.get('fieldMapping', {})
413
+ before_paths = set(all_field_paths(before))
414
+ after_paths = set(all_field_paths(after))
415
+ removed_fields = []
416
+ for path in before_paths:
417
+ if map_field(mapping, path) not in after_paths:
418
+ removed_fields.append(path)
419
+ added_fields = []
420
+ for path in after_paths:
421
+ if not any(map_field(mapping, before_path) == path for before_path in before_paths):
422
+ added_fields.append(path)
423
+ for entity in removed_entities:
424
+ findings.append({'code': 'DD3_REMOVED_ENTITY', 'severity': 'warning', 'message': f'BEFORE entity {entity} is not present in AFTER', 'evidence': {'entity': entity}})
425
+ for field in removed_fields:
426
+ findings.append({'code': 'DD3_REMOVED_FIELD', 'severity': 'warning', 'message': f'BEFORE field {field} is not present in AFTER', 'evidence': {'field': field}})
427
+ detail = f"Delta: +{len(added_entities)} entities, -{len(removed_entities)} entities, +{len(added_fields)} fields, -{len(removed_fields)} fields"
428
+ return check_result('DD3', findings, detail)
429
+
430
+
431
+ def dd4_breaking_change_regression(before, after, options):
432
+ findings = []
433
+ after_field_map = {}
434
+ for path in all_field_paths(after):
435
+ entity_name, field_name = path.split('.', 1)
436
+ after_field_map[path] = field_by_path(after, entity_name, field_name)
437
+ mapping = options.get('fieldMapping', {})
438
+ migration_from = {m.get('from') for m in options.get('migrationMappings', [])}
439
+ for entity in before.get('entities', []):
440
+ for field in entity.get('fields', []):
441
+ path = entity.get('name') + '.' + field.get('name')
442
+ mapped = map_field(mapping, path)
443
+ after_field = after_field_map.get(mapped)
444
+ if after_field is None:
445
+ if field.get('required') is True and path not in migration_from:
446
+ findings.append({'code': 'DD4_REQUIRED_FIELD_REMOVED', 'severity': 'error', 'message': f'Required field {path} was removed without migration mapping', 'evidence': {'beforeField': path}})
447
+ continue
448
+ if field.get('required') is not True and after_field.get('required') is True and path not in migration_from:
449
+ findings.append({'code': 'DD4_OPTIONAL_BECAME_REQUIRED', 'severity': 'warning', 'message': f'Optional field {path} became required in AFTER', 'evidence': {'beforeField': path, 'afterField': mapped}})
450
+ if field.get('nullable') is not False and after_field.get('nullable') is False and path not in migration_from:
451
+ findings.append({'code': 'DD4_NULLABLE_BECAME_NOT_NULL', 'severity': 'warning', 'message': f'Nullable field {path} became non-nullable in AFTER', 'evidence': {'beforeField': path, 'afterField': mapped}})
452
+ if field.get('type') != after_field.get('type') and path not in migration_from:
453
+ findings.append({'code': 'DD4_TYPE_CHANGED', 'severity': 'warning', 'message': f'Field type changed for {path}', 'evidence': {'beforeField': path, 'beforeType': field.get('type'), 'afterType': after_field.get('type')}})
454
+ return check_result('DD4', findings, 'Breaking change regression')
455
+
456
+
457
+ def run_checks(model, options):
458
+ checks = [
459
+ ds1_schema_well_formed(model),
460
+ ds2_required_completeness(model, options),
461
+ ds3_relationship_integrity(model),
462
+ ds4_type_nullability_consistency(model),
463
+ da1_null_empty_injection(model),
464
+ da2_boundary_blast(model),
465
+ da3_uniqueness_violation(model),
466
+ da4_referential_integrity_violation(model),
467
+ da5_migration_coverage(model, options),
468
+ da6_copy_consistency(model, options),
469
+ da7_rollback_backup_symmetry(model, options),
470
+ da8_idempotent_constraints(model, options),
471
+ da9_data_monotonic(model, options),
472
+ da10_data_sequence(model, options),
473
+ da11_data_leads_to(model, options),
474
+ da12_data_atomicity(model, options),
475
+ ]
476
+ if options.get('beforeModel') is not None:
477
+ checks.extend([
478
+ dd1_data_behavior_preservation(options['beforeModel'], model, options),
479
+ dd2_data_invariant_continuity(options['beforeModel'], model, options),
480
+ dd3_delta_summary(options['beforeModel'], model, options),
481
+ dd4_breaking_change_regression(options['beforeModel'], model, options),
482
+ ])
483
+ return checks
484
+
485
+
486
+ def main():
487
+ parser = argparse.ArgumentParser(description='DataModelV1 verification harness')
488
+ parser.add_argument('--model', required=True, help='Path to AFTER DataModelV1 JSON file')
489
+ parser.add_argument('--before-model', help='Path to BEFORE DataModelV1 JSON file')
490
+ parser.add_argument('--field-mapping', help='Path to field mapping JSON object')
491
+ parser.add_argument('--copy-pairs', help='Path to copy pairs JSON array')
492
+ parser.add_argument('--migration-mappings', help='Path to migration mappings JSON array')
493
+ parser.add_argument('--backup-pairs', help='Path to backup pairs JSON array')
494
+ args = parser.parse_args()
495
+
496
+ model = load_json(args.model)
497
+ options = {}
498
+ if args.before_model:
499
+ options['beforeModel'] = load_json(args.before_model)
500
+ if args.field_mapping:
501
+ options['fieldMapping'] = load_json(args.field_mapping)
502
+ if args.copy_pairs:
503
+ options['copyPairs'] = load_json(args.copy_pairs)
504
+ if args.migration_mappings:
505
+ options['migrationMappings'] = load_json(args.migration_mappings)
506
+ if args.backup_pairs:
507
+ options['backupPairs'] = load_json(args.backup_pairs)
508
+
509
+ checks = run_checks(model, options)
510
+ errors = sum(check['errors'] for check in checks)
511
+ warnings = sum(check['warnings'] for check in checks)
512
+
513
+ print('=' * 60)
514
+ print('DATA MODEL VERIFICATION REPORT')
515
+ print('=' * 60)
516
+ for check in checks:
517
+ status = 'PASS' if not check['findings'] else 'FAIL'
518
+ print(f"[{status}] {check['id']}: {check['detail']}")
519
+ for finding in check['findings']:
520
+ print(f" - [{finding['severity']}] {finding['code']}: {finding['message']}")
521
+ print('=' * 60)
522
+ print(f'SUMMARY: {errors} errors, {warnings} warnings')
523
+ print('=' * 60)
524
+ sys.exit(1 if errors > 0 else 0)
525
+
526
+
527
+ if __name__ == '__main__':
528
+ main()