core-runtime-engine 11.5.1__py3-none-any.whl

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 (96) hide show
  1. core_runtime/__init__.py +10 -0
  2. core_runtime/__version__.py +17 -0
  3. core_runtime/cli/__init__.py +7 -0
  4. core_runtime/cli/__main__.py +22 -0
  5. core_runtime/cli/bump_version.py +176 -0
  6. core_runtime/cli/contract_preflight.py +69 -0
  7. core_runtime/cli/create_domain.py +66 -0
  8. core_runtime/cli/doctor.py +44 -0
  9. core_runtime/cli/inventory.py +85 -0
  10. core_runtime/cli/lint.py +8 -0
  11. core_runtime/cli/main.py +579 -0
  12. core_runtime/cli/release_check.py +65 -0
  13. core_runtime/cli/repair_artifact_paths.py +48 -0
  14. core_runtime/cli/sync_template.py +67 -0
  15. core_runtime/cli/validate.py +73 -0
  16. core_runtime/core/__init__.py +34 -0
  17. core_runtime/core/audit_event.py +760 -0
  18. core_runtime/core/audit_trail_index.py +100 -0
  19. core_runtime/core/canonicalization.py +55 -0
  20. core_runtime/core/contract_evaluator.py +945 -0
  21. core_runtime/core/contract_executability.py +307 -0
  22. core_runtime/core/contract_loader.py +57 -0
  23. core_runtime/core/contract_probes.py +630 -0
  24. core_runtime/core/contract_program.py +131 -0
  25. core_runtime/core/contract_program_registry.py +104 -0
  26. core_runtime/core/contract_program_v2.py +126 -0
  27. core_runtime/core/dsk_v3.py +142 -0
  28. core_runtime/core/explainability.py +2115 -0
  29. core_runtime/core/numeric_normalization.py +120 -0
  30. core_runtime/core/rule_anchor.py +1388 -0
  31. core_runtime/core/schema_fingerprint.py +69 -0
  32. core_runtime/core/sensor_evidence.py +548 -0
  33. core_runtime/data/contracts/CoreAnchor.sol +109 -0
  34. core_runtime/data/contracts/CoreRuleAnchor.abi.json +111 -0
  35. core_runtime/data/contracts/CoreRuleAnchor.bin +1 -0
  36. core_runtime/data/contracts/CoreRuleAnchor.build.json +20 -0
  37. core_runtime/data/contracts/CoreRuleAnchor.runtime.bin +1 -0
  38. core_runtime/data/contracts/CoreRuleAnchor.sol +74 -0
  39. core_runtime/data/package_data_manifest.v1.json +173 -0
  40. core_runtime/data/schemas/core/causal_trace.v1.json +141 -0
  41. core_runtime/data/schemas/core/context_gate.v1.json +38 -0
  42. core_runtime/data/schemas/core/context_threshold.v1.json +46 -0
  43. core_runtime/data/schemas/core/contract_program.v1.json +186 -0
  44. core_runtime/data/schemas/core/contract_program.v2.json +187 -0
  45. core_runtime/data/schemas/core/control_decision.v1.json +114 -0
  46. core_runtime/data/schemas/core/dsk.v3.json +105 -0
  47. core_runtime/data/schemas/core/effect_result.v1.json +39 -0
  48. core_runtime/data/schemas/core/entropy_signal.v1.json +108 -0
  49. core_runtime/data/schemas/core/execution_receipt.v1.json +93 -0
  50. core_runtime/data/schemas/core/frozen_release_manifest.v1.json +87 -0
  51. core_runtime/data/schemas/core/frozen_release_manifest.v2.json +72 -0
  52. core_runtime/data/schemas/core/frozen_release_manifest.v3.json +38 -0
  53. core_runtime/data/schemas/core/frozen_release_manifest.v4.json +72 -0
  54. core_runtime/data/schemas/core/frozen_release_manifest.v5.json +38 -0
  55. core_runtime/data/schemas/core/frozen_release_manifest.v6.json +116 -0
  56. core_runtime/data/schemas/core/frozen_release_manifest.v7.json +37 -0
  57. core_runtime/data/schemas/core/frozen_release_manifest.v8.json +114 -0
  58. core_runtime/data/schemas/core/frozen_rule_set.v1.json +282 -0
  59. core_runtime/data/schemas/core/memory_artifact.v1.json +120 -0
  60. core_runtime/data/schemas/core/memory_generation_result.v1.json +37 -0
  61. core_runtime/data/schemas/core/operational_learning_event.v1.json +63 -0
  62. core_runtime/data/schemas/core/pattern_candidate.v1.json +114 -0
  63. core_runtime/data/schemas/core/physical_safety_assurance_case.v1.json +676 -0
  64. core_runtime/data/schemas/core/policy_lifecycle.v1.json +99 -0
  65. core_runtime/data/schemas/core/retention_manifest.v1.json +53 -0
  66. core_runtime/data/schemas/core/reversibility_policy.v1.json +107 -0
  67. core_runtime/data/schemas/core/rule_anchor_batch.v1.json +93 -0
  68. core_runtime/data/schemas/core/rule_anchor_chain_evidence.v1.json +56 -0
  69. core_runtime/data/schemas/core/rule_approval.v1.json +49 -0
  70. core_runtime/data/schemas/core/rule_approval_request.v1.json +42 -0
  71. core_runtime/data/schemas/core/state_transition.v1.json +115 -0
  72. core_runtime/data/schemas/core/task_closeout.v1.json +47 -0
  73. core_runtime/data/schemas/core/template_promotion_candidate.v1.json +83 -0
  74. core_runtime/data/schemas/core/unsigned_rule_anchor_deployment.v1.json +106 -0
  75. core_runtime/data/schemas/core/unsigned_rule_anchor_transaction.v1.json +116 -0
  76. core_runtime/tooling/__init__.py +48 -0
  77. core_runtime/tooling/bump_version.py +900 -0
  78. core_runtime/tooling/contract_preflight.py +293 -0
  79. core_runtime/tooling/create_domain.py +254 -0
  80. core_runtime/tooling/diagnostics.py +129 -0
  81. core_runtime/tooling/doctor.py +482 -0
  82. core_runtime/tooling/file_inventory.py +182 -0
  83. core_runtime/tooling/json_checks.py +100 -0
  84. core_runtime/tooling/release_check.py +1017 -0
  85. core_runtime/tooling/repair_artifact_paths.py +458 -0
  86. core_runtime/tooling/report_writer.py +176 -0
  87. core_runtime/tooling/repository_inventory.py +399 -0
  88. core_runtime/tooling/safety_checks.py +172 -0
  89. core_runtime/tooling/sync_template.py +303 -0
  90. core_runtime/tooling/validation.py +507 -0
  91. core_runtime/tooling/version_inventory.py +256 -0
  92. core_runtime_engine-11.5.1.dist-info/METADATA +35 -0
  93. core_runtime_engine-11.5.1.dist-info/RECORD +96 -0
  94. core_runtime_engine-11.5.1.dist-info/WHEEL +5 -0
  95. core_runtime_engine-11.5.1.dist-info/entry_points.txt +2 -0
  96. core_runtime_engine-11.5.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,900 @@
1
+ """Bump-version planner and controlled mutation engine.
2
+
3
+ Slice 2: dry-run planner — compute proposed changes without mutation.
4
+ Slice 3: controlled mutation — apply version bump with safety flags.
5
+
6
+ Reuses version extraction patterns from VersionInventory to discover
7
+ current versions across the repository, then computes the exact text
8
+ replacements that would be performed by a real version bump.
9
+
10
+ Dry-run mode never writes files. Apply mode writes files only after
11
+ all validation passes, using transactional semantics.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import re
18
+ import subprocess
19
+ from dataclasses import dataclass
20
+ from datetime import date
21
+ from pathlib import Path
22
+ from typing import Optional
23
+
24
+ from core_runtime.tooling.diagnostics import DiagnosticCollection, ExitCode
25
+ from core_runtime.tooling.version_inventory import VersionInventory
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # SemVer validation (CORE format: MAJOR.MINOR.PATCH, no pre-release tags)
29
+ # ---------------------------------------------------------------------------
30
+ _SEMVER_CORE_RE = re.compile(r"^\d+\.\d+\.\d+$")
31
+
32
+
33
+ def validate_target_version(version: str) -> Optional[str]:
34
+ """Return None if *version* is valid, otherwise an error message.
35
+
36
+ CORE accepts only strict MAJOR.MINOR.PATCH — no leading ``v``, no
37
+ pre-release suffixes, no missing components.
38
+ """
39
+ if not _SEMVER_CORE_RE.match(version):
40
+ return (
41
+ "Invalid version format: '{0}'. "
42
+ "Expected MAJOR.MINOR.PATCH (e.g. 10.5.1). "
43
+ "No leading 'v', no pre-release suffixes, no missing components.".format(version)
44
+ )
45
+ return None
46
+
47
+
48
+ def parse_version_tuple(version: str) -> tuple[int, int, int]:
49
+ """Parse a MAJOR.MINOR.PATCH string into a comparable tuple."""
50
+ parts = version.split(".")
51
+ return (int(parts[0]), int(parts[1]), int(parts[2]))
52
+
53
+
54
+ def check_version_movement(current: str, target: str, diagnostics: DiagnosticCollection) -> bool:
55
+ """Return True if target > current, otherwise add a blocked diagnostic and return False."""
56
+ current_t = parse_version_tuple(current)
57
+ target_t = parse_version_tuple(target)
58
+ if target_t == current_t:
59
+ diagnostics.add_blocked(
60
+ code="core.bump_version.target_not_greater",
61
+ message="Target version must be greater than current version.",
62
+ path="target_version",
63
+ expected="> {0}".format(current),
64
+ actual=target,
65
+ )
66
+ return False
67
+ if target_t < current_t:
68
+ diagnostics.add_blocked(
69
+ code="core.bump_version.target_not_greater",
70
+ message="Target version must be greater than current version.",
71
+ path="target_version",
72
+ expected="> {0}".format(current),
73
+ actual=target,
74
+ )
75
+ return False
76
+ return True
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # Approved mutation file set (Slice 3 allowlist)
81
+ # ---------------------------------------------------------------------------
82
+ APPROVED_MUTATION_FILES: set[str] = {
83
+ "core_runtime/__version__.py",
84
+ "pyproject.toml",
85
+ "core_runtime/__init__.py", # included via replacement rules
86
+ "README.md",
87
+ "docs/VERSIONING_POLICY.md",
88
+ "docs/CORE_RELEASE_README.md",
89
+ "CHANGELOG.md",
90
+ "docs/releases/README.md",
91
+ }
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # File change description
96
+ # ---------------------------------------------------------------------------
97
+ @dataclass
98
+ class PlannedChange:
99
+ """A single file that would be changed by a version bump."""
100
+
101
+ path: str
102
+ would_change: bool
103
+ replacement_count: int = 0
104
+ details: str = ""
105
+
106
+ def to_dict(self) -> dict:
107
+ return {
108
+ "path": self.path,
109
+ "would_change": self.would_change,
110
+ "replacement_count": self.replacement_count,
111
+ }
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # Applied change description (for apply report — mutable files)
116
+ # ---------------------------------------------------------------------------
117
+ @dataclass
118
+ class AppliedChange:
119
+ """A single file that was changed by a version bump apply."""
120
+
121
+ path: str
122
+ changed: bool
123
+ replacement_count: int = 0
124
+
125
+ def to_dict(self) -> dict:
126
+ return {
127
+ "path": self.path,
128
+ "changed": self.changed,
129
+ "replacement_count": self.replacement_count,
130
+ }
131
+
132
+
133
+ # ---------------------------------------------------------------------------
134
+ # Replacement rule — describes one regex → substitution
135
+ # ---------------------------------------------------------------------------
136
+ @dataclass
137
+ class ReplacementRule:
138
+ """A regex-pattern + replacement pair for a single file."""
139
+
140
+ file_rel: str
141
+ pattern: str
142
+ replacement_template: str # use {old} and {new} placeholders
143
+
144
+ def compute_replacement(self, text: str, old_version: str, new_version: str) -> tuple[int, str]:
145
+ """Apply the replacement to *text*, returning (count, new_text)."""
146
+ repl = self.replacement_template.format(old=old_version, new=new_version)
147
+ new_text, count = re.subn(self.pattern, repl, text, flags=re.MULTILINE)
148
+ return count, new_text
149
+
150
+
151
+ # ---------------------------------------------------------------------------
152
+ # BumpVersionPlanner — the main dry-run + apply engine
153
+ # ---------------------------------------------------------------------------
154
+ # Files and their replacement rules.
155
+ # Each rule is (file_rel, regex_pattern, replacement_template).
156
+ # {old} = current version, {new} = target version in the template.
157
+
158
+ DEFAULT_REPLACEMENT_RULES: list[ReplacementRule] = [
159
+ # core_runtime/__version__.py — __version__ = "X.Y.Z"
160
+ ReplacementRule(
161
+ file_rel="core_runtime/__version__.py",
162
+ pattern=r'^__version__\s*=\s*["\']\d+\.\d+\.\d+["\']',
163
+ replacement_template='__version__ = "{new}"',
164
+ ),
165
+ # core_runtime/__version__.py — CORE_VERSION = "X.Y.Z"
166
+ ReplacementRule(
167
+ file_rel="core_runtime/__version__.py",
168
+ pattern=r'^CORE_VERSION\s*=\s*["\']\d+\.\d+\.\d+["\']',
169
+ replacement_template='CORE_VERSION = "{new}"',
170
+ ),
171
+ # pyproject.toml — version = "X.Y.Z"
172
+ ReplacementRule(
173
+ file_rel="pyproject.toml",
174
+ pattern=r'^version\s*=\s*["\']\d+\.\d+\.\d+["\']',
175
+ replacement_template='version = "{new}"',
176
+ ),
177
+ # core_runtime/__init__.py — Version: X.Y.Z (docstring line)
178
+ ReplacementRule(
179
+ file_rel="core_runtime/__init__.py",
180
+ pattern=r"^Version:\s*\d+\.\d+\.\d+",
181
+ replacement_template="Version: {new}",
182
+ ),
183
+ # README.md — **Current version**: CORE vX.Y.Z
184
+ ReplacementRule(
185
+ file_rel="README.md",
186
+ pattern=r"(\*\*Current version\*\*:\s*CORE\s+v)\d+\.\d+\.\d+",
187
+ replacement_template=r"\g<1>{new}",
188
+ ),
189
+ # docs/VERSIONING_POLICY.md — **Current**: vX.Y.Z
190
+ ReplacementRule(
191
+ file_rel="docs/VERSIONING_POLICY.md",
192
+ pattern=r"(\*\*Current\*\*:\s*v)\d+\.\d+\.\d+",
193
+ replacement_template=r"\g<1>{new}",
194
+ ),
195
+ # docs/VERSIONING_POLICY.md — Current: vX.Y.Z (alternate form)
196
+ ReplacementRule(
197
+ file_rel="docs/VERSIONING_POLICY.md",
198
+ pattern=r"(Current\s*:\s*v)\d+\.\d+\.\d+",
199
+ replacement_template=r"\g<1>{new}",
200
+ ),
201
+ # docs/VERSIONING_POLICY.md — CORE stable releases: `vX.Y.Z`
202
+ ReplacementRule(
203
+ file_rel="docs/VERSIONING_POLICY.md",
204
+ pattern=r"(CORE\s+stable\s+releases:\s*`v)\d+\.\d+\.\d+(`)",
205
+ replacement_template=r"\g<1>{new}\2",
206
+ ),
207
+ # CHANGELOG.md — ## vX.Y.Z (latest entry heading) — only matches FIRST heading
208
+ ReplacementRule(
209
+ file_rel="CHANGELOG.md",
210
+ pattern=r"^(##\s+v)\d+\.\d+\.\d+",
211
+ replacement_template=r"\g<1>{new}",
212
+ ),
213
+ # docs/CORE_RELEASE_README.md — CORE: vX.Y.Z
214
+ ReplacementRule(
215
+ file_rel="docs/CORE_RELEASE_README.md",
216
+ pattern=r"(CORE:\s*v)\d+\.\d+\.\d+",
217
+ replacement_template=r"\g<1>{new}",
218
+ ),
219
+ ]
220
+
221
+
222
+ class BumpVersionPlanner:
223
+ """Compute, report, and apply version bumps with controlled mutation."""
224
+
225
+ # Files that must be inspected even if no replacement rule matches
226
+ # (they are listed in the plan as "would be inspected").
227
+ INSPECT_ONLY_FILES: list[str] = [
228
+ "docs/releases/",
229
+ ]
230
+
231
+ def __init__(
232
+ self,
233
+ repo_root: Path,
234
+ rules: Optional[list[ReplacementRule]] = None,
235
+ ):
236
+ self.repo_root = repo_root
237
+ self.rules = rules if rules is not None else DEFAULT_REPLACEMENT_RULES
238
+ self._version_inv = VersionInventory(repo_root)
239
+
240
+ # ---- public API -------------------------------------------------------
241
+
242
+ def plan(
243
+ self,
244
+ target_version: str,
245
+ diagnostics: DiagnosticCollection,
246
+ ) -> tuple[list[PlannedChange], dict]:
247
+ """Compute the dry-run plan for bumping from current to *target_version*.
248
+
249
+ Returns (changes, summary).
250
+ """
251
+ # 1. Validate target version
252
+ err = validate_target_version(target_version)
253
+ if err:
254
+ diagnostics.add_blocked(
255
+ code="core.bump_version.invalid_target",
256
+ message=err,
257
+ path="target_version",
258
+ actual=target_version,
259
+ )
260
+ return [], {}
261
+
262
+ # 2. Discover current canonical version
263
+ current_version = self._version_inv.get_canonical_version()
264
+ if current_version is None:
265
+ diagnostics.add_blocked(
266
+ code="core.bump_version.canonical_missing",
267
+ message="Cannot determine current canonical version from core_runtime/__version__.py",
268
+ path="core_runtime/__version__.py",
269
+ )
270
+ return [], {}
271
+
272
+ # 3. Verify consistency via Slice 1 logic
273
+ self._version_inv.check_consistency(diagnostics)
274
+ if diagnostics.has_blocked():
275
+ diagnostics.add_blocked(
276
+ code="core.bump_version.version_inconsistent",
277
+ message="Version consistency check failed — cannot proceed with dry-run",
278
+ path="core_runtime/__version__.py",
279
+ expected=target_version,
280
+ actual="inconsistent",
281
+ )
282
+ return [], {}
283
+
284
+ # 4. Compute replacements per file
285
+ changes = self._compute_file_changes(current_version, target_version, diagnostics)
286
+
287
+ # 5. Build summary
288
+ files_checked = len(changes)
289
+ files_that_would_change = sum(1 for c in changes if c.would_change)
290
+ total_replacements = sum(c.replacement_count for c in changes)
291
+ counts = diagnostics.count_by_severity()
292
+
293
+ summary = {
294
+ "files_checked": files_checked,
295
+ "files_that_would_change": files_that_would_change,
296
+ "replacement_count": total_replacements,
297
+ "info": counts.get("info", 0),
298
+ "warning": counts.get("warning", 0),
299
+ "error": counts.get("error", 0),
300
+ "blocked": counts.get("blocked", 0),
301
+ }
302
+
303
+ return changes, summary
304
+
305
+ def apply(
306
+ self,
307
+ target_version: str,
308
+ confirm_current: str,
309
+ diagnostics: DiagnosticCollection,
310
+ ) -> tuple[list[AppliedChange], dict]:
311
+ """Apply the version bump from current to *target_version*.
312
+
313
+ Requires *confirm_current* to match the canonical version.
314
+ Performs transactional mutation with safety checks.
315
+
316
+ Returns (applied_changes, summary).
317
+ """
318
+ # 1. Validate target version format
319
+ err = validate_target_version(target_version)
320
+ if err:
321
+ diagnostics.add_blocked(
322
+ code="core.bump_version.invalid_target",
323
+ message=err,
324
+ path="target_version",
325
+ actual=target_version,
326
+ )
327
+ return [], {}
328
+
329
+ # 2. Discover current canonical version
330
+ current_version = self._version_inv.get_canonical_version()
331
+ if current_version is None:
332
+ diagnostics.add_blocked(
333
+ code="core.bump_version.canonical_missing",
334
+ message="Cannot determine current canonical version from core_runtime/__version__.py",
335
+ path="core_runtime/__version__.py",
336
+ )
337
+ return [], {}
338
+
339
+ # 3. Confirm current version matches
340
+ if confirm_current != current_version:
341
+ diagnostics.add_blocked(
342
+ code="core.bump_version.confirm_current_mismatch",
343
+ message="--confirm-current value does not match the canonical version.",
344
+ path="core_runtime/__version__.py",
345
+ expected=current_version,
346
+ actual=confirm_current,
347
+ )
348
+ return [], {}
349
+
350
+ # 4. Verify version consistency
351
+ self._version_inv.check_consistency(diagnostics)
352
+ if diagnostics.has_blocked():
353
+ diagnostics.add_blocked(
354
+ code="core.bump_version.version_inconsistent",
355
+ message="Version consistency check failed — cannot proceed with apply",
356
+ path="core_runtime/__version__.py",
357
+ expected=target_version,
358
+ actual="inconsistent",
359
+ )
360
+ return [], {}
361
+
362
+ # 5. Check version movement (target > current)
363
+ if not check_version_movement(current_version, target_version, diagnostics):
364
+ return [], {}
365
+
366
+ # 6. Git safety check — version-bearing files must be clean
367
+ self._check_git_safety(diagnostics)
368
+ if diagnostics.has_blocked():
369
+ return [], {}
370
+
371
+ # 7. Compute all new file contents in memory (transactional step)
372
+ try:
373
+ new_contents = self._compute_new_contents(current_version, target_version, diagnostics)
374
+ except Exception as exc:
375
+ diagnostics.add_blocked(
376
+ code="core.bump_version.internal_error",
377
+ message="Failed to compute new file contents: {0}".format(exc),
378
+ path="internal",
379
+ )
380
+ return [], {}
381
+
382
+ if diagnostics.has_blocked():
383
+ return [], {}
384
+
385
+ # 8. Check allowlist — all files in new_contents must be approved
386
+ for file_rel in new_contents:
387
+ if file_rel not in APPROVED_MUTATION_FILES:
388
+ diagnostics.add_blocked(
389
+ code="core.bump_version.file_not_in_allowlist",
390
+ message="File is not in the approved mutation allowlist: {0}".format(file_rel),
391
+ path=file_rel,
392
+ )
393
+ return [], {}
394
+
395
+ # 9. Write files (transactional — only after all validations pass)
396
+ written_files: list[str] = []
397
+ try:
398
+ for file_rel, new_text in new_contents.items():
399
+ full_path = self.repo_root / file_rel
400
+ full_path.parent.mkdir(parents=True, exist_ok=True)
401
+ full_path.write_text(new_text, encoding="utf-8")
402
+ written_files.append(file_rel)
403
+ except Exception:
404
+ # Partial mutation — report blocked and list touched files
405
+ diagnostics.add_blocked(
406
+ code="core.bump_version.internal_error",
407
+ message="Partial mutation: write failed. Touched files: {0}".format(
408
+ ", ".join(written_files)
409
+ ),
410
+ path="internal",
411
+ )
412
+ return [], {}
413
+
414
+ # 10. Create release note for target version (preferred path)
415
+ release_note_path = self.repo_root / "docs" / "releases" / "v{0}.md".format(target_version)
416
+ if not release_note_path.exists():
417
+ self._create_release_note(target_version, release_note_path)
418
+ written_files.append("docs/releases/v{0}.md".format(target_version))
419
+
420
+ # 11. docs/releases/README.md already mutated in new_contents (compute-time).
421
+ # No post-write pass here — see _compute_new_contents for the regex.
422
+
423
+ # 12. Re-read changed files and build applied changes report
424
+ applied: list[AppliedChange] = []
425
+ for file_rel, new_text in new_contents.items():
426
+ full_path = self.repo_root / file_rel
427
+ if full_path.exists():
428
+ after = full_path.read_text(encoding="utf-8")
429
+ repl_count = after.count(target_version)
430
+ applied.append(AppliedChange(
431
+ path=file_rel,
432
+ changed=True,
433
+ replacement_count=repl_count if repl_count > 0 else 1,
434
+ ))
435
+ else:
436
+ applied.append(AppliedChange(
437
+ path=file_rel,
438
+ changed=False,
439
+ replacement_count=0,
440
+ ))
441
+
442
+ # 13. Re-run version inventory to verify consistency
443
+ post_inv = VersionInventory(self.repo_root)
444
+ post_diagnostics = DiagnosticCollection()
445
+ post_inv.check_consistency(post_diagnostics)
446
+ if post_diagnostics.has_errors() or post_diagnostics.has_blocked():
447
+ for d in post_diagnostics.diagnostics:
448
+ diagnostics.add_error(
449
+ code=d.code,
450
+ message="[post-apply] {0}".format(d.message),
451
+ path=d.path,
452
+ )
453
+
454
+ # Build summary
455
+ files_changed = sum(1 for a in applied if a.changed)
456
+ total_replacements = sum(a.replacement_count for a in applied)
457
+ counts = diagnostics.count_by_severity()
458
+
459
+ summary = {
460
+ "files_checked": len(applied),
461
+ "files_changed": files_changed,
462
+ "replacement_count": total_replacements,
463
+ "info": counts.get("info", 0),
464
+ "warning": counts.get("warning", 0),
465
+ "error": counts.get("error", 0),
466
+ "blocked": counts.get("blocked", 0),
467
+ }
468
+
469
+ return applied, summary
470
+
471
+ def report_json(
472
+ self,
473
+ target_version: str,
474
+ current_version: str,
475
+ changes: list[PlannedChange],
476
+ summary: dict,
477
+ diagnostics: DiagnosticCollection,
478
+ output_path: Optional[Path] = None,
479
+ *,
480
+ mode: str = "dry-run",
481
+ mutation_performed: bool = False,
482
+ applied_changes: Optional[list[AppliedChange]] = None,
483
+ ) -> dict:
484
+ """Build the JSON report dict and optionally write to *output_path*."""
485
+ exit_code = diagnostics.compute_exit_code()
486
+ status_map = {
487
+ ExitCode.OK: "pass",
488
+ ExitCode.ERROR: "error",
489
+ ExitCode.BLOCKED: "blocked",
490
+ ExitCode.INTERNAL_ERROR: "internal_error",
491
+ }
492
+
493
+ report = {
494
+ "tool": "core-runtime bump-version",
495
+ "mode": mode,
496
+ "status": status_map.get(exit_code, "internal_error"),
497
+ "mutation_performed": mutation_performed,
498
+ "current_version": current_version,
499
+ "target_version": target_version,
500
+ "summary": summary,
501
+ "changes": [],
502
+ "diagnostics": [d.to_dict() for d in diagnostics.diagnostics],
503
+ }
504
+
505
+ if applied_changes is not None:
506
+ report["changes"] = [c.to_dict() for c in applied_changes]
507
+ else:
508
+ report["changes"] = [c.to_dict() for c in changes]
509
+
510
+ if output_path is not None:
511
+ output_path.parent.mkdir(parents=True, exist_ok=True)
512
+ output_path.write_text(
513
+ json.dumps(report, indent=2, ensure_ascii=False) + "\n",
514
+ encoding="utf-8",
515
+ )
516
+
517
+ return report
518
+
519
+ def report_markdown(
520
+ self,
521
+ target_version: str,
522
+ current_version: str,
523
+ changes: list[PlannedChange],
524
+ summary: dict,
525
+ diagnostics: DiagnosticCollection,
526
+ output_path: Optional[Path] = None,
527
+ *,
528
+ mode: str = "dry-run",
529
+ mutation_performed: bool = False,
530
+ applied_changes: Optional[list[AppliedChange]] = None,
531
+ ) -> str:
532
+ """Build the Markdown report and optionally write to *output_path*."""
533
+ exit_code = diagnostics.compute_exit_code()
534
+ status_map = {
535
+ ExitCode.OK: "PASS",
536
+ ExitCode.ERROR: "ERROR",
537
+ ExitCode.BLOCKED: "BLOCKED",
538
+ ExitCode.INTERNAL_ERROR: "INTERNAL_ERROR",
539
+ }
540
+ status_label = status_map.get(exit_code, "UNKNOWN")
541
+
542
+ title = "# CORE bump-version apply report" if mode == "apply" else "# CORE bump-version dry-run"
543
+
544
+ lines: list[str] = []
545
+ lines.append(title)
546
+ lines.append("")
547
+ lines.append("## Summary")
548
+ lines.append("")
549
+ if mode == "apply":
550
+ lines.append("- Files checked: {0}".format(summary.get("files_checked", 0)))
551
+ lines.append("- Files changed: {0}".format(summary.get("files_changed", 0)))
552
+ else:
553
+ lines.append("- Files checked: {0}".format(summary.get("files_checked", 0)))
554
+ lines.append("- Files that would change: {0}".format(summary.get("files_that_would_change", 0)))
555
+ lines.append("- Total replacements: {0}".format(summary.get("replacement_count", 0)))
556
+ lines.append("- Diagnostics: {0} info, {1} warning, {2} error, {3} blocked".format(
557
+ summary.get("info", 0),
558
+ summary.get("warning", 0),
559
+ summary.get("error", 0),
560
+ summary.get("blocked", 0),
561
+ ))
562
+ lines.append("")
563
+
564
+ lines.append("## Current Version")
565
+ lines.append("")
566
+ lines.append("`{0}`".format(current_version))
567
+ lines.append("")
568
+
569
+ lines.append("## Target Version")
570
+ lines.append("")
571
+ lines.append("`{0}`".format(target_version))
572
+ lines.append("")
573
+
574
+ if applied_changes is not None:
575
+ lines.append("## Files Changed")
576
+ lines.append("")
577
+ if applied_changes:
578
+ lines.append("| File | Changed | Replacements |")
579
+ lines.append("| --- | --- | --- |")
580
+ for c in applied_changes:
581
+ ch = "Yes" if c.changed else "No"
582
+ lines.append("| {0} | {1} | {2} |".format(c.path, ch, c.replacement_count))
583
+ else:
584
+ lines.append("No changes applied.")
585
+ else:
586
+ lines.append("## Planned Changes")
587
+ lines.append("")
588
+ if changes:
589
+ lines.append("| File | Would Change | Replacements |")
590
+ lines.append("| --- | --- | --- |")
591
+ for c in changes:
592
+ wc = "Yes" if c.would_change else "No"
593
+ lines.append("| {0} | {1} | {2} |".format(c.path, wc, c.replacement_count))
594
+ else:
595
+ lines.append("No changes computed (dry-run blocked or no files to inspect).")
596
+ lines.append("")
597
+
598
+ lines.append("## Diagnostics")
599
+ lines.append("")
600
+ diag_items = diagnostics.diagnostics
601
+ if diag_items:
602
+ for d in diag_items:
603
+ lines.append("- [{0}] {1}: {2}".format(
604
+ d.severity.value.upper(), d.code, d.message,
605
+ ))
606
+ else:
607
+ lines.append("No diagnostics.")
608
+ lines.append("")
609
+
610
+ lines.append("## Validation")
611
+ lines.append("")
612
+ lines.append("- `python -m core_runtime.cli lint --scope tooling --format json`")
613
+ lines.append("- `python -m core_runtime.cli bump-version {0} --dry-run --format json`".format(target_version))
614
+ lines.append("- `python -m pytest tests/test_tooling_*.py -v`")
615
+ lines.append("")
616
+
617
+ lines.append("## Final Status")
618
+ lines.append("")
619
+ mut_label = "true" if mutation_performed else "false"
620
+ lines.append("**{0}** (mutation_performed: {1})".format(status_label, mut_label))
621
+ lines.append("")
622
+
623
+ md = "\n".join(lines)
624
+
625
+ if output_path is not None:
626
+ output_path.parent.mkdir(parents=True, exist_ok=True)
627
+ output_path.write_text(md, encoding="utf-8")
628
+
629
+ return md
630
+
631
+ # ---- internals -------------------------------------------------------
632
+
633
+ def _compute_file_changes(
634
+ self,
635
+ current_version: str,
636
+ target_version: str,
637
+ diagnostics: DiagnosticCollection,
638
+ ) -> list[PlannedChange]:
639
+ """Walk replacement rules, count how many matches each file has."""
640
+ # Group rules by file
641
+ file_rules: dict[str, list[ReplacementRule]] = {}
642
+ for rule in self.rules:
643
+ file_rules.setdefault(rule.file_rel, []).append(rule)
644
+
645
+ changes: list[PlannedChange] = []
646
+ for file_rel, rules in file_rules.items():
647
+ full_path = self.repo_root / file_rel
648
+
649
+ if not full_path.is_file():
650
+ # Directory-like paths (e.g. docs/releases/) — report only
651
+ if full_path.is_dir():
652
+ changes.append(PlannedChange(
653
+ path=file_rel,
654
+ would_change=False,
655
+ replacement_count=0,
656
+ details="directory (report-only)",
657
+ ))
658
+ continue
659
+ # Missing file
660
+ changes.append(PlannedChange(
661
+ path=file_rel,
662
+ would_change=False,
663
+ replacement_count=0,
664
+ details="file not found",
665
+ ))
666
+ diagnostics.add_info(
667
+ code="core.bump_version.file_missing",
668
+ message="File not found for dry-run inspection: {0}".format(file_rel),
669
+ path=file_rel,
670
+ )
671
+ continue
672
+
673
+ # Read file, count matches per rule
674
+ try:
675
+ text = full_path.read_text(encoding="utf-8")
676
+ except OSError as exc:
677
+ changes.append(PlannedChange(
678
+ path=file_rel,
679
+ would_change=False,
680
+ replacement_count=0,
681
+ details="read error: {0}".format(exc),
682
+ ))
683
+ continue
684
+
685
+ total_hits = 0
686
+ for rule in rules:
687
+ matches = re.findall(rule.pattern, text, re.MULTILINE)
688
+ total_hits += len(matches)
689
+
690
+ changes.append(PlannedChange(
691
+ path=file_rel,
692
+ would_change=total_hits > 0,
693
+ replacement_count=total_hits,
694
+ ))
695
+
696
+ # Add inspect-only entries
697
+ for rel in self.INSPECT_ONLY_FILES:
698
+ changes.append(PlannedChange(
699
+ path=rel,
700
+ would_change=False,
701
+ replacement_count=0,
702
+ details="inspect-only (no replacement rules)",
703
+ ))
704
+
705
+ return changes
706
+
707
+ def _compute_new_contents(
708
+ self,
709
+ current_version: str,
710
+ target_version: str,
711
+ diagnostics: DiagnosticCollection,
712
+ ) -> dict[str, str]:
713
+ """Compute all new file contents in memory (no writes).
714
+
715
+ Returns a dict mapping file_rel → new_text.
716
+ """
717
+ # Group rules by file
718
+ file_rules: dict[str, list[ReplacementRule]] = {}
719
+ for rule in self.rules:
720
+ file_rules.setdefault(rule.file_rel, []).append(rule)
721
+
722
+ new_contents: dict[str, str] = {}
723
+
724
+ for file_rel, rules in file_rules.items():
725
+ full_path = self.repo_root / file_rel
726
+ if not full_path.is_file():
727
+ continue
728
+
729
+ try:
730
+ text = full_path.read_text(encoding="utf-8")
731
+ except OSError:
732
+ continue
733
+
734
+ new_text = text
735
+ total_hits = 0
736
+ for rule in rules:
737
+ count, new_text = rule.compute_replacement(new_text, current_version, target_version)
738
+ total_hits += count
739
+
740
+ if total_hits > 0:
741
+ new_contents[file_rel] = new_text
742
+
743
+ # Handle CHANGELOG.md — preferred: prepend new entry (not replace header)
744
+ # The replacement rule changes only the FIRST "## v" heading.
745
+ # For the "Unreleased" → new version pattern in this repo, we also
746
+ # insert a new changelog section if the file has "## Unreleased" before
747
+ # the "## v" heading.
748
+ changelog_path = self.repo_root / "CHANGELOG.md"
749
+ if changelog_path.is_file() and "CHANGELOG.md" in new_contents:
750
+ self._handle_changelog_insert(new_contents, current_version, target_version, diagnostics)
751
+
752
+ # Handle docs/releases/README.md — update latest pointer.
753
+ # Done here (compute-time, not write-time) so new_contents stays authoritative
754
+ # and the mutation is reported via the AppliedChange list.
755
+ releases_readme_rel = "docs/releases/README.md"
756
+ releases_readme = self.repo_root / releases_readme_rel
757
+ if releases_readme.is_file():
758
+ try:
759
+ text = releases_readme.read_text(encoding="utf-8")
760
+ # Update "Latest stable release: vX.Y.Z (`docs/releases/vX.Y.Z.md`)"
761
+ pattern = re.compile(
762
+ r"(\*\*Latest stable release\*\*:\s*v)\d+\.\d+\.\d+(\s+\(`docs/releases/v)"
763
+ r"\d+\.\d+\.\d+(\.md`\))",
764
+ )
765
+ new_text, count = pattern.subn(
766
+ r"\g<1>{0}\g<2>{0}\g<3>".format(target_version),
767
+ text,
768
+ )
769
+ if count > 0:
770
+ # Update "Previous stable release: vX.Y.Z" line
771
+ prev_pattern = re.compile(
772
+ r"(Previous stable release:\s*v)\d+\.\d+\.\d+",
773
+ )
774
+ new_text = prev_pattern.sub(
775
+ r"\g<1>{0}".format(current_version),
776
+ new_text,
777
+ )
778
+ new_contents[releases_readme_rel] = new_text
779
+ except OSError:
780
+ pass
781
+
782
+ return new_contents
783
+
784
+ def _handle_changelog_insert(
785
+ self,
786
+ new_contents: dict[str, str],
787
+ current_version: str,
788
+ target_version: str,
789
+ diagnostics: DiagnosticCollection,
790
+ ) -> None:
791
+ """Adjust CHANGELOG.md content in new_contents dict.
792
+
793
+ Preferred: insert a new section after "## Unreleased" heading
794
+ for the target version. The replacement rule already changed
795
+ the latest "## v" heading from current to new version.
796
+
797
+ Since this repo keeps "## Unreleased" before the latest version
798
+ heading, and the replacement rule renames the heading from
799
+ v{current} to v{target}, the changelog is already correct for
800
+ the simple case. But we also need to ensure "## v{current}"
801
+ is preserved as a historical entry.
802
+
803
+ Strategy: The replacement rule changes the first "## v10.5.0"
804
+ heading to "## v10.5.1". This is correct because the repo uses
805
+ "## Unreleased" as the pending section and the first versioned
806
+ heading IS the latest release. We just need to make sure we
807
+ don't also rewrite the historical entries.
808
+ """
809
+ changelog_text = new_contents.get("CHANGELOG.md", "")
810
+ if not changelog_text:
811
+ return
812
+
813
+ # The replacement rule only matches the FIRST "## v" heading due to
814
+ # MULTILINE + no count limit re.subn. However re.subn without count
815
+ # replaces ALL matches. We need to change only the FIRST one.
816
+ # Let's recompute: apply replacement to ONLY the first heading.
817
+ original_path = self.repo_root / "CHANGELOG.md"
818
+ try:
819
+ original_text = original_path.read_text(encoding="utf-8")
820
+ except OSError:
821
+ return
822
+
823
+ # Find the first "## v" heading and replace only that one
824
+ pattern = re.compile(r"^(##\s+v)\d+\.\d+\.\d+", re.MULTILINE)
825
+ match = pattern.search(original_text)
826
+ if match:
827
+ # Replace only the first match
828
+ old_heading = match.group(0)
829
+ new_heading = "## v{0}".format(target_version)
830
+ new_text = original_text.replace(old_heading, new_heading, 1)
831
+ new_contents["CHANGELOG.md"] = new_text
832
+
833
+ def _check_git_safety(self, diagnostics: DiagnosticCollection) -> None:
834
+ """Check that approved version-bearing files have no uncommitted changes."""
835
+ try:
836
+ result = subprocess.run(
837
+ ["git", "status", "--porcelain"] + sorted(APPROVED_MUTATION_FILES),
838
+ capture_output=True,
839
+ text=True,
840
+ cwd=str(self.repo_root),
841
+ timeout=10,
842
+ )
843
+ except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
844
+ # Git unavailable — emit warning but continue
845
+ diagnostics.add_info(
846
+ code="core.bump_version.git_unavailable",
847
+ message="Git not available; skipping dirty file check.",
848
+ path="internal",
849
+ )
850
+ return
851
+
852
+ if result.returncode != 0:
853
+ diagnostics.add_info(
854
+ code="core.bump_version.git_error",
855
+ message="git status returned non-zero; skipping dirty file check.",
856
+ path="internal",
857
+ )
858
+ return
859
+
860
+ dirty_lines = [line for line in result.stdout.strip().splitlines() if line.strip()]
861
+ for line in dirty_lines:
862
+ # git status --porcelain format: XY PATH
863
+ parts = line.strip().split(None, 1)
864
+ if len(parts) < 2:
865
+ continue
866
+ filepath = parts[1].strip('"')
867
+ # Check if this is an approved version-bearing file (or a subpath of one)
868
+ for approved in APPROVED_MUTATION_FILES:
869
+ if filepath == approved or filepath.startswith(approved + "/"):
870
+ diagnostics.add_blocked(
871
+ code="core.bump_version.dirty_version_file",
872
+ message="Version-bearing file has uncommitted changes before apply.",
873
+ path=filepath,
874
+ )
875
+ return
876
+
877
+ def _create_release_note(self, target_version: str, path: Path) -> None:
878
+ """Create a minimal release note for the target version."""
879
+ today = date.today().isoformat()
880
+ content = (
881
+ "# CORE v{0}\n"
882
+ "\n"
883
+ "Date: {1}\n"
884
+ "\n"
885
+ "## Summary\n"
886
+ "\n"
887
+ "- Tooling release: controlled bump-version mutation support.\n"
888
+ "\n"
889
+ "## Validation\n"
890
+ "\n"
891
+ "- `python -m core_runtime.cli lint --scope tooling --format json`\n"
892
+ "- `python -m core_runtime.cli bump-version {0} --dry-run --format json`\n"
893
+ "- `python -m pytest tests/test_tooling_*.py -v`\n"
894
+ "\n"
895
+ "## Notes\n"
896
+ "\n"
897
+ "No runtime behavior, schema, or domain contract changes.\n"
898
+ ).format(target_version, today)
899
+ path.parent.mkdir(parents=True, exist_ok=True)
900
+ path.write_text(content, encoding="utf-8")