its-magic 0.1.3-2 → 0.1.3-4

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.
@@ -0,0 +1,304 @@
1
+ #!/usr/bin/env python3
2
+ """Validator: US-0120 closure-verification.md schema
3
+
4
+ Required fields (Q6 LOCKED, .md format):
5
+ - story_id (US-XXXX pattern)
6
+ - closure_date (ISO-8601 UTC)
7
+ - closure_role (qe|curator)
8
+ - pre_closure_status (OPEN)
9
+ - post_closure_status (DONE)
10
+ - release_evidence_refs[] (array of paths)
11
+ - isolation_evidence{} (object)
12
+ - runtime_proof{} (object)
13
+
14
+ Optional fields:
15
+ - normalization_notes
16
+ - backward_compat_note
17
+
18
+ Exit 0 / 1.
19
+ """
20
+
21
+ import argparse
22
+ import json
23
+ import re
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ STORY_ID_RE = re.compile(r'^US-\d{4}$')
28
+ ISO_DATE_RE = re.compile(r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$')
29
+
30
+ REQUIRED_FIELDS = [
31
+ 'story_id',
32
+ 'closure_date',
33
+ 'closure_role',
34
+ 'pre_closure_status',
35
+ 'post_closure_status',
36
+ 'release_evidence_refs',
37
+ 'isolation_evidence',
38
+ 'runtime_proof',
39
+ ]
40
+
41
+ OPTIONAL_FIELDS = [
42
+ 'normalization_notes',
43
+ 'backward_compat_note',
44
+ ]
45
+
46
+
47
+ def parse_md_frontmatter(text: str) -> dict:
48
+ """Parse YAML-like frontmatter from .md file."""
49
+ if not text.startswith('---'):
50
+ return {}
51
+
52
+ parts = text.split('---', 2)
53
+ if len(parts) < 3:
54
+ return {}
55
+
56
+ frontmatter = {}
57
+ for line in parts[1].strip().splitlines():
58
+ line = line.strip()
59
+ if ':' in line:
60
+ key, _, value = line.partition(':')
61
+ key = key.strip()
62
+ value = value.strip()
63
+
64
+ # Try to parse as JSON for arrays and objects
65
+ if value.startswith('[') or value.startswith('{'):
66
+ try:
67
+ value = json.loads(value)
68
+ except json.JSONDecodeError:
69
+ pass
70
+
71
+ # Strip quotes
72
+ if isinstance(value, str) and len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
73
+ value = value[1:-1]
74
+
75
+ frontmatter[key] = value
76
+
77
+ return frontmatter
78
+
79
+
80
+ def validate_story_id(value: str) -> bool:
81
+ return bool(STORY_ID_RE.match(value))
82
+
83
+
84
+ def validate_closure_date(value: str) -> bool:
85
+ return bool(ISO_DATE_RE.match(value))
86
+
87
+
88
+ def validate_closure_role(value: str) -> bool:
89
+ return value in ('qe', 'curator')
90
+
91
+
92
+ def validate_pre_status(value: str) -> bool:
93
+ return value == 'OPEN'
94
+
95
+
96
+ def validate_post_status(value: str) -> bool:
97
+ return value == 'DONE'
98
+
99
+
100
+ def validate_release_evidence_refs(value) -> bool:
101
+ if isinstance(value, list):
102
+ return all(isinstance(ref, str) and ref.strip() for ref in value)
103
+ if isinstance(value, str):
104
+ try:
105
+ parsed = json.loads(value)
106
+ return isinstance(parsed, list) and all(isinstance(r, str) for r in parsed)
107
+ except:
108
+ return False
109
+ return False
110
+
111
+
112
+ def validate_isolation_evidence(value) -> bool:
113
+ if isinstance(value, dict):
114
+ required_keys = {'phase_id', 'role', 'fresh_context_marker', 'timestamp', 'evidence_ref'}
115
+ return required_keys.issubset(value.keys())
116
+ if isinstance(value, str):
117
+ try:
118
+ parsed = json.loads(value)
119
+ return isinstance(parsed, dict)
120
+ except:
121
+ return False
122
+ return False
123
+
124
+
125
+ def validate_runtime_proof(value) -> bool:
126
+ if isinstance(value, dict):
127
+ required_keys = {'runtime_proof_id', 'proof_hash', 'proof_ttl'}
128
+ return required_keys.issubset(value.keys())
129
+ if isinstance(value, str):
130
+ try:
131
+ parsed = json.loads(value)
132
+ return isinstance(parsed, dict)
133
+ except:
134
+ return False
135
+ return False
136
+
137
+
138
+ VALIDATORS = {
139
+ 'story_id': validate_story_id,
140
+ 'closure_date': validate_closure_date,
141
+ 'closure_role': validate_closure_role,
142
+ 'pre_closure_status': validate_pre_status,
143
+ 'post_closure_status': validate_post_status,
144
+ 'release_evidence_refs': validate_release_evidence_refs,
145
+ 'isolation_evidence': validate_isolation_evidence,
146
+ 'runtime_proof': validate_runtime_proof,
147
+ }
148
+
149
+
150
+ def validate(file_path: str, silent: bool = False) -> tuple[bool, list[str]]:
151
+ """Validate closure-verification.md file. Returns (pass, errors)."""
152
+ path = Path(file_path)
153
+
154
+ if not path.exists():
155
+ return False, [f"File not found: {file_path}"]
156
+
157
+ try:
158
+ text = path.read_text(encoding='utf-8')
159
+ except Exception as e:
160
+ return False, [f"Failed to read file: {e}"]
161
+
162
+ frontmatter = parse_md_frontmatter(text)
163
+
164
+ errors = []
165
+
166
+ # Check required fields exist
167
+ for field in REQUIRED_FIELDS:
168
+ if field not in frontmatter:
169
+ errors.append(f"Missing required field: {field}")
170
+ continue
171
+
172
+ value = frontmatter[field]
173
+
174
+ # Check optional fields don't need validation
175
+ if field in OPTIONAL_FIELDS:
176
+ continue
177
+
178
+ validator = VALIDATORS.get(field)
179
+ if validator and not validator(value):
180
+ errors.append(f"Invalid value for {field}: {value}")
181
+
182
+ # Check for optional unrecognized fields? No, schema allows extras for extensibility
183
+
184
+ if not silent:
185
+ if errors:
186
+ print(f"[VALIDATE_CLOSURE_VERIFICATION_FAIL] {file_path}", file=sys.stderr)
187
+ for err in errors:
188
+ print(f" - {err}", file=sys.stderr)
189
+ else:
190
+ print(f"[VALIDATE_CLOSURE_VERIFICATION_OK] {file_path}")
191
+
192
+ return len(errors) == 0, errors
193
+
194
+
195
+ def main():
196
+ parser = argparse.ArgumentParser(description='Validate closure-verification.md (US-0120)')
197
+ parser.add_argument('file', nargs='?', help='Path to closure-verification.md')
198
+ parser.add_argument('--self-test', action='store_true', help='Self-test validator schema enforcement')
199
+ parser.add_argument('--silent', action='store_true', help='Silent mode (exit code only)')
200
+
201
+ args = parser.parse_args()
202
+
203
+ if args.self_test:
204
+ # Self-test: validate schema enforcement
205
+ test_cases = [
206
+ # Valid case
207
+ ('valid', {
208
+ 'story_id': 'US-0120',
209
+ 'closure_date': '2026-07-07T22:00:00Z',
210
+ 'closure_role': 'qe',
211
+ 'pre_closure_status': 'OPEN',
212
+ 'post_closure_status': 'DONE',
213
+ 'release_evidence_refs': ['handoffs/releases/S0120-release-notes.md'],
214
+ 'isolation_evidence': {
215
+ 'phase_id': 'closure',
216
+ 'role': 'qe',
217
+ 'fresh_context_marker': 'qe-US0120-closure-20260707T220000Z-fresh',
218
+ 'timestamp': '2026-07-07T22:00:00Z',
219
+ 'evidence_ref': 'sprints/S0120/closure-verification.md'
220
+ },
221
+ 'runtime_proof': {
222
+ 'runtime_proof_id': 'rp-auto-20260707-execute-qe-20260707T220000Z-US-0120',
223
+ 'proof_hash': 'a' * 64,
224
+ 'proof_ttl': '2026-07-07T23:00:00Z'
225
+ }
226
+ }, True),
227
+
228
+ # Invalid story_id
229
+ ('invalid_story_id', {
230
+ 'story_id': 'US-abc',
231
+ 'closure_date': '2026-07-07T22:00:00Z',
232
+ 'closure_role': 'qe',
233
+ 'pre_closure_status': 'OPEN',
234
+ 'post_closure_status': 'DONE',
235
+ 'release_evidence_refs': ['path'],
236
+ 'isolation_evidence': {'phase_id': 'x'},
237
+ 'runtime_proof': {'runtime_proof_id': 'x'}
238
+ }, False),
239
+
240
+ # Invalid role
241
+ ('invalid_role', {
242
+ 'story_id': 'US-0120',
243
+ 'closure_date': '2026-07-07T22:00:00Z',
244
+ 'closure_role': 'dev',
245
+ 'pre_closure_status': 'OPEN',
246
+ 'post_closure_status': 'DONE',
247
+ 'release_evidence_refs': ['path'],
248
+ 'isolation_evidence': {'phase_id': 'x'},
249
+ 'runtime_proof': {'runtime_proof_id': 'x'}
250
+ }, False),
251
+
252
+ # Missing required field
253
+ ('missing_field', {
254
+ 'story_id': 'US-0120',
255
+ 'closure_date': '2026-07-07T22:00:00Z'
256
+ }, False),
257
+ ]
258
+
259
+ all_pass = True
260
+ for name, mock_data, expected_pass in test_cases:
261
+ # Test validation logic directly
262
+ result = True
263
+ errors_list = []
264
+
265
+ # Check story_id
266
+ if 'story_id' in mock_data:
267
+ if not validate_story_id(mock_data['story_id']):
268
+ result = False
269
+
270
+ # Check closure_role
271
+ if 'closure_role' in mock_data:
272
+ if not validate_closure_role(mock_data['closure_role']):
273
+ result = False
274
+
275
+ # Check missing required fields
276
+ for field in REQUIRED_FIELDS:
277
+ if field not in mock_data:
278
+ errors_list.append(f"Missing required field: {field}")
279
+
280
+ if errors_list:
281
+ result = False
282
+
283
+ if result != expected_pass:
284
+ print(f"SELF_TEST_FAIL {name}: expected={expected_pass} got={result}", file=sys.stderr)
285
+ all_pass = False
286
+ else:
287
+ print(f"SELF_TEST_OK {name}")
288
+
289
+ if all_pass:
290
+ print("[VALIDATE_CLOSURE_VERIFICATION_SELF_TEST_OK]")
291
+ return 0
292
+ else:
293
+ print("[VALIDATE_CLOSURE_VERIFICATION_SELF_TEST_FAIL]", file=sys.stderr)
294
+ return 1
295
+
296
+ if not args.file:
297
+ parser.error("file argument required (or use --self-test)")
298
+
299
+ passed, errors = validate(args.file, silent=args.silent)
300
+ return 0 if passed else 1
301
+
302
+
303
+ if __name__ == '__main__':
304
+ sys.exit(main())