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.
- core_runtime/__init__.py +10 -0
- core_runtime/__version__.py +17 -0
- core_runtime/cli/__init__.py +7 -0
- core_runtime/cli/__main__.py +22 -0
- core_runtime/cli/bump_version.py +176 -0
- core_runtime/cli/contract_preflight.py +69 -0
- core_runtime/cli/create_domain.py +66 -0
- core_runtime/cli/doctor.py +44 -0
- core_runtime/cli/inventory.py +85 -0
- core_runtime/cli/lint.py +8 -0
- core_runtime/cli/main.py +579 -0
- core_runtime/cli/release_check.py +65 -0
- core_runtime/cli/repair_artifact_paths.py +48 -0
- core_runtime/cli/sync_template.py +67 -0
- core_runtime/cli/validate.py +73 -0
- core_runtime/core/__init__.py +34 -0
- core_runtime/core/audit_event.py +760 -0
- core_runtime/core/audit_trail_index.py +100 -0
- core_runtime/core/canonicalization.py +55 -0
- core_runtime/core/contract_evaluator.py +945 -0
- core_runtime/core/contract_executability.py +307 -0
- core_runtime/core/contract_loader.py +57 -0
- core_runtime/core/contract_probes.py +630 -0
- core_runtime/core/contract_program.py +131 -0
- core_runtime/core/contract_program_registry.py +104 -0
- core_runtime/core/contract_program_v2.py +126 -0
- core_runtime/core/dsk_v3.py +142 -0
- core_runtime/core/explainability.py +2115 -0
- core_runtime/core/numeric_normalization.py +120 -0
- core_runtime/core/rule_anchor.py +1388 -0
- core_runtime/core/schema_fingerprint.py +69 -0
- core_runtime/core/sensor_evidence.py +548 -0
- core_runtime/data/contracts/CoreAnchor.sol +109 -0
- core_runtime/data/contracts/CoreRuleAnchor.abi.json +111 -0
- core_runtime/data/contracts/CoreRuleAnchor.bin +1 -0
- core_runtime/data/contracts/CoreRuleAnchor.build.json +20 -0
- core_runtime/data/contracts/CoreRuleAnchor.runtime.bin +1 -0
- core_runtime/data/contracts/CoreRuleAnchor.sol +74 -0
- core_runtime/data/package_data_manifest.v1.json +173 -0
- core_runtime/data/schemas/core/causal_trace.v1.json +141 -0
- core_runtime/data/schemas/core/context_gate.v1.json +38 -0
- core_runtime/data/schemas/core/context_threshold.v1.json +46 -0
- core_runtime/data/schemas/core/contract_program.v1.json +186 -0
- core_runtime/data/schemas/core/contract_program.v2.json +187 -0
- core_runtime/data/schemas/core/control_decision.v1.json +114 -0
- core_runtime/data/schemas/core/dsk.v3.json +105 -0
- core_runtime/data/schemas/core/effect_result.v1.json +39 -0
- core_runtime/data/schemas/core/entropy_signal.v1.json +108 -0
- core_runtime/data/schemas/core/execution_receipt.v1.json +93 -0
- core_runtime/data/schemas/core/frozen_release_manifest.v1.json +87 -0
- core_runtime/data/schemas/core/frozen_release_manifest.v2.json +72 -0
- core_runtime/data/schemas/core/frozen_release_manifest.v3.json +38 -0
- core_runtime/data/schemas/core/frozen_release_manifest.v4.json +72 -0
- core_runtime/data/schemas/core/frozen_release_manifest.v5.json +38 -0
- core_runtime/data/schemas/core/frozen_release_manifest.v6.json +116 -0
- core_runtime/data/schemas/core/frozen_release_manifest.v7.json +37 -0
- core_runtime/data/schemas/core/frozen_release_manifest.v8.json +114 -0
- core_runtime/data/schemas/core/frozen_rule_set.v1.json +282 -0
- core_runtime/data/schemas/core/memory_artifact.v1.json +120 -0
- core_runtime/data/schemas/core/memory_generation_result.v1.json +37 -0
- core_runtime/data/schemas/core/operational_learning_event.v1.json +63 -0
- core_runtime/data/schemas/core/pattern_candidate.v1.json +114 -0
- core_runtime/data/schemas/core/physical_safety_assurance_case.v1.json +676 -0
- core_runtime/data/schemas/core/policy_lifecycle.v1.json +99 -0
- core_runtime/data/schemas/core/retention_manifest.v1.json +53 -0
- core_runtime/data/schemas/core/reversibility_policy.v1.json +107 -0
- core_runtime/data/schemas/core/rule_anchor_batch.v1.json +93 -0
- core_runtime/data/schemas/core/rule_anchor_chain_evidence.v1.json +56 -0
- core_runtime/data/schemas/core/rule_approval.v1.json +49 -0
- core_runtime/data/schemas/core/rule_approval_request.v1.json +42 -0
- core_runtime/data/schemas/core/state_transition.v1.json +115 -0
- core_runtime/data/schemas/core/task_closeout.v1.json +47 -0
- core_runtime/data/schemas/core/template_promotion_candidate.v1.json +83 -0
- core_runtime/data/schemas/core/unsigned_rule_anchor_deployment.v1.json +106 -0
- core_runtime/data/schemas/core/unsigned_rule_anchor_transaction.v1.json +116 -0
- core_runtime/tooling/__init__.py +48 -0
- core_runtime/tooling/bump_version.py +900 -0
- core_runtime/tooling/contract_preflight.py +293 -0
- core_runtime/tooling/create_domain.py +254 -0
- core_runtime/tooling/diagnostics.py +129 -0
- core_runtime/tooling/doctor.py +482 -0
- core_runtime/tooling/file_inventory.py +182 -0
- core_runtime/tooling/json_checks.py +100 -0
- core_runtime/tooling/release_check.py +1017 -0
- core_runtime/tooling/repair_artifact_paths.py +458 -0
- core_runtime/tooling/report_writer.py +176 -0
- core_runtime/tooling/repository_inventory.py +399 -0
- core_runtime/tooling/safety_checks.py +172 -0
- core_runtime/tooling/sync_template.py +303 -0
- core_runtime/tooling/validation.py +507 -0
- core_runtime/tooling/version_inventory.py +256 -0
- core_runtime_engine-11.5.1.dist-info/METADATA +35 -0
- core_runtime_engine-11.5.1.dist-info/RECORD +96 -0
- core_runtime_engine-11.5.1.dist-info/WHEEL +5 -0
- core_runtime_engine-11.5.1.dist-info/entry_points.txt +2 -0
- 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")
|