Skip to content

Commit e90cab5

Browse files
Merge pull request #184 from BlueEventHorizon/fix/180-nested-version-path-scope
[Fix] ネスト version_path が親スコープ外の version を誤更新する欠陥を修正 (#180)
2 parents 9465932 + e6f537c commit e90cab5

3 files changed

Lines changed: 121 additions & 14 deletions

File tree

docs/specs/forge/design/DES-023_version_management_workflow_design.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,7 @@ _update_with_path(content, old_version, new_version, version_path)
293293
```
294294
295295
1. `version_path` の最終キー名を含む行を正規表現で検索
296-
2. ネストパスの場合は親キーチェーン(`a.b.version` なら `a` → `b`)を先頭から順にたどり、1階層見つけるたびに検索開始位置をその直後まで前進させてから最終キー名を探す(Issue #175: 直近の親キー1つだけを見ると、同名の別ブロックに誤って一致する事故があったため、既存の「1階層分の絞り込み」をN階層に一般化した。JSON の波括弧・TOML のセクション境界までは追跡しない — テキストベース置換の設計原則 §1 を維持するため、意図的に「開始位置の前進のみ」に留める。そのため親キー通過後に同名ブロックが再度現れる病的なケースまでは救えない)
296+
2. ネストパスの場合は親キーチェーン(`a.b.version` なら `a` → `b`)を先頭から順にたどり、各親キーの直後に開く `{` とその対応 `}`(ダブルクォート文字列内の波括弧は無視)をスコープ境界として、次階層以降の検索範囲をスコープ内に制限してから最終キー名を探す(Issue #175: 直近の親キー1つだけを見ると同名の別ブロックに誤って一致する事故があったため N 階層のチェーン絞り込みに一般化。Issue #180: 開始位置の前進のみではスコープ境界を見ないため、`b.package.version` が存在しなくても `b` より後方の別スコープ `package.version` を誤更新できた — 波括弧スコープの追跡を追加し、親キーの値がオブジェクトでない・境界を特定できない場合は書き換えず `ValueError` に倒す。置換自体はテキスト操作のまま維持する — 設計原則 §1)。対応形式は JSON のオブジェクトおよび TOML の inline table(`key = { ... }`)。TOML のセクション形式(`[package]`)のネストパスは従来から対象外
297297
3. JSON の `"version": "X.Y.Z"` と TOML の `version = "X.Y.Z"` の両方に対応
298298
4. version 末尾に境界(`(?![\d.])`)を設け、`0.6.1` が `0.6.10` を前方一致で破壊しないようにする(Issue #115 レビュー指摘)。同様にフィールド名・親キー名の直前にも語境界(`\b`)を設け、`other_version` が `version` に誤って一致しないようにする(Issue #175 単独修正レビューで検出)
299299
5. 未置換時のエラーは filter モードと同様に 2 種類に分離される(Issue #175):

plugins/forge/skills/update-version/scripts/update_version_files.py

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -163,21 +163,30 @@ def _update_with_path(content, old_version, new_version, version_path):
163163
return result
164164

165165
# ネストフィールド: 親キーチェーン(parts[:-1])を先頭から順にたどり、
166-
# 1階層見つけるたびに検索開始位置をその直後まで前進させる。
167-
# 3階層以上のパス(例: "b.package.version")で、直近の親キー名("package")だけを
168-
# 見ると同名の別ブロック("a.package.version")に誤って一致しうるため、
169-
# 中間の親キー("b")も含めて順にチェーンを絞り込む(Issue #175 単独修正レビューで検出)。
170-
# JSON の波括弧・TOML のセクション境界までは追跡しない(DES-023 §1 のテキストベース
171-
# 置換方針を維持するため、意図的に「開始位置の前進」のみに留める)。
172-
start = 0
166+
# 各親キーの直後に開く `{` の対応 `}` までをスコープとして検索範囲を制限する。
167+
# 開始位置の前進だけではスコープ境界を見ないため、`b.package.version` が
168+
# 存在しなくても `b` より後方の別スコープ `package.version` を誤更新できてしまう
169+
# (Issue #180)。親キーの値がオブジェクトでない・境界を特定できない場合は
170+
# 書き換えずエラーに倒す。置換自体はテキスト操作のまま(DES-023 §1)。
171+
start, end = 0, len(content)
173172
for parent_key in parts[:-1]:
174-
parent_pattern = re.compile(r'[\"\']?\b' + re.escape(parent_key) + r'[\"\']?\s*[:={]')
175-
parent_match = parent_pattern.search(content, start)
173+
parent_pattern = re.compile(r'[\"\']?\b' + re.escape(parent_key) + r'[\"\']?\s*[:=]?\s*\{')
174+
parent_match = parent_pattern.search(content, start, end)
176175
if not parent_match:
177-
raise ValueError(f"親キー '{parent_key}' が見つかりません(version_path: '{version_path}')")
178-
start = parent_match.end()
176+
raise ValueError(
177+
f"親キー '{parent_key}' のオブジェクトブロックが見つかりません"
178+
f"(version_path: '{version_path}')"
179+
)
180+
open_pos = parent_match.end() - 1
181+
close_pos = _find_matching_brace(content, open_pos)
182+
if close_pos is None or close_pos > end:
183+
raise ValueError(
184+
f"親キー '{parent_key}' のブロック境界を特定できません"
185+
f"(version_path: '{version_path}')"
186+
)
187+
start, end = parent_match.end(), close_pos
179188

180-
sub_content = content[start:]
189+
sub_content = content[start:end]
181190
result, count = pattern.subn(lambda m: m.group(1) + f'"{new_version}"', sub_content, count=1)
182191

183192
if count == 0:
@@ -188,7 +197,37 @@ def _update_with_path(content, old_version, new_version, version_path):
188197
)
189198
raise ValueError(f"フィールド '{version_path}' にバージョン '{old_version}' が見つかりません")
190199

191-
return content[:start] + result
200+
return content[:start] + result + content[end:]
201+
202+
203+
def _find_matching_brace(content, open_pos):
204+
"""open_pos の `{` に対応する `}` の位置を返す。見つからなければ None。
205+
206+
ダブルクォート文字列内の波括弧はネストとして数えない
207+
(JSON / TOML basic string の `\\"` エスケープを考慮する)。
208+
"""
209+
depth = 0
210+
in_string = False
211+
i = open_pos
212+
n = len(content)
213+
while i < n:
214+
c = content[i]
215+
if in_string:
216+
if c == '\\':
217+
i += 2
218+
continue
219+
if c == '"':
220+
in_string = False
221+
elif c == '"':
222+
in_string = True
223+
elif c == '{':
224+
depth += 1
225+
elif c == '}':
226+
depth -= 1
227+
if depth == 0:
228+
return i
229+
i += 1
230+
return None
192231

193232

194233
def _update_with_filter(content, old_version, new_version, filter_pattern,

tests/forge/update-version/test_update_version_files.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,74 @@ def test_three_level_path_with_duplicate_intermediate_key_updates_correct_block(
9898
self.assertIn('"a": {"package": {"version": "999.88.7"}}', result)
9999
self.assertIn('"b": {"package": {"version": "999.88.8"}}', result)
100100

101+
def test_nested_path_missing_does_not_update_sibling_scope(self):
102+
"""Issue #180: b.package.version が存在しない場合、b より後方の
103+
トップレベル package.version を誤更新せず ValueError を送出する"""
104+
content = (
105+
'{\n'
106+
' "b": {},\n'
107+
' "package": {"version": "999.88.7"}\n'
108+
'}'
109+
)
110+
with self.assertRaises(ValueError):
111+
update_version_in_text(
112+
content, "999.88.7", "999.88.8", version_path="b.package.version"
113+
)
114+
115+
def test_nested_path_parent_value_not_object_raises(self):
116+
"""Issue #180: 親キーの値がオブジェクトでない場合は境界を特定できないため
117+
後方の同名フィールドを更新せず ValueError を送出する"""
118+
content = (
119+
'{\n'
120+
' "package": "not-an-object",\n'
121+
' "other": {"version": "999.88.7"}\n'
122+
'}'
123+
)
124+
with self.assertRaises(ValueError):
125+
update_version_in_text(
126+
content, "999.88.7", "999.88.8", version_path="package.version"
127+
)
128+
129+
def test_nested_path_drift_detected_within_scope_only(self):
130+
"""Issue #180: ドリフト判定もスコープ内に限定される。
131+
b.package.version は存在するが値が不一致 → VersionDriftError
132+
(後方の一致する値に流れない)"""
133+
content = (
134+
'{\n'
135+
' "b": {"package": {"version": "1.0.0"}},\n'
136+
' "package": {"version": "999.88.7"}\n'
137+
'}'
138+
)
139+
with self.assertRaises(VersionDriftError):
140+
update_version_in_text(
141+
content, "999.88.7", "999.88.8", version_path="b.package.version"
142+
)
143+
144+
def test_nested_path_brace_in_string_does_not_break_scope(self):
145+
"""Issue #180 境界: 文字列値に含まれる波括弧がスコープ判定を壊さない"""
146+
content = (
147+
'{\n'
148+
' "package": {\n'
149+
' "description": "uses {braces} and \\"quotes\\"",\n'
150+
' "version": "999.88.7"\n'
151+
' }\n'
152+
'}'
153+
)
154+
result = update_version_in_text(
155+
content, "999.88.7", "999.88.8", version_path="package.version"
156+
)
157+
self.assertIn('"version": "999.88.8"', result)
158+
159+
def test_nested_path_toml_inline_table(self):
160+
"""Issue #180 回帰確認: TOML inline table 形式(key = { ... })でも
161+
スコープ内の version を更新できる"""
162+
content = 'package = { name = "x", version = "999.88.7" }\nversion = "0.1.0"\n'
163+
result = update_version_in_text(
164+
content, "999.88.7", "999.88.8", version_path="package.version"
165+
)
166+
self.assertIn('version = "999.88.8"', result)
167+
self.assertIn('version = "0.1.0"', result)
168+
101169
def test_path_prefix_version_not_corrupted(self):
102170
"""境界: old=0.6.1 が "0.6.10" を前方一致で破壊しない
103171
(見つからないが、フィールド自体は存在するため VersionDriftError)"""

0 commit comments

Comments
 (0)