Skip to content

Commit faa62d4

Browse files
Merge pull request #185 from BlueEventHorizon/fix/181-shared-numbering-duplicates
[Fix] scan_spec_ids の duplicates を共有採番モードに追随させ同一履歴由来の誤報告を解消 (#181)
2 parents e90cab5 + 95cae5a commit faa62d4

4 files changed

Lines changed: 211 additions & 46 deletions

File tree

plugins/forge/skills/next-spec-id/SKILL.md

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ python3 "$SCRIPT" ADR --share-prefixes ADR,DES
6565

6666
#### 重複検出時
6767

68+
`duplicates` に報告されるのは「**異なるファイルが同じ ID(共有採番モードでは同じ番号)を主張している**」衝突のみ。同一パスのファイルが複数ブランチに存在するだけの同一履歴由来ケースは報告されない(Issue #181)。共有採番モード(`--share-prefixes`)では、`ADR-032``DES-032` のような prefix 違い・同番号も衝突として報告される。
69+
6870
```json
6971
{
7072
"status": "ok",
@@ -77,16 +79,22 @@ python3 "$SCRIPT" ADR --share-prefixes ADR,DES
7779
"duplicates": [
7880
{
7981
"id": "SCR-013",
80-
"branches": ["feature/edit_pickup", "origin/feature/print_letter"]
81-
},
82-
{
83-
"id": "SCR-014",
84-
"branches": ["feature/edit_pickup", "origin/feature/print_letter"]
82+
"ids": ["SCR-013"],
83+
"branches": ["feature/edit_pickup", "origin/feature/print_letter"],
84+
"paths": [
85+
"docs/specs/pickup/requirements/SCR-013_edit_pickup.md",
86+
"docs/specs/letter/requirements/SCR-013_print_letter.md"
87+
]
8588
}
8689
]
8790
}
8891
```
8992

93+
- `id`: 代表 ID(衝突に関与する ID の先頭)
94+
- `ids`: 衝突に関与する全 ID(共有採番モードでは `["ADR-032", "DES-032"]` のように複数になりうる)
95+
- `paths`: 同じ番号を主張している異なるファイルパス
96+
- `branches`: 該当ファイルが存在するブランチ
97+
9098
#### エラー時
9199

92100
```json
@@ -143,5 +151,5 @@ ADR は設計書と同じディレクトリに配置するため、`.doc_structu
143151
4. ベースブランチから派生した全ブランチをスキャン(ローカル + リモート)
144152
5. `git ls-tree` で各ブランチの specs ディレクトリを走査
145153
6. 指定プレフィックスの最大番号を検出
146-
7. 重複があれば `duplicates` に記録
154+
7. 異なるファイルが同じ ID / 共有番号を主張する衝突があれば `duplicates` に記録(同一パス由来の複数ブランチ出現は正常として除外)
147155
8. 最大番号 + 1 を返す

plugins/forge/skills/next-spec-id/scripts/scan_spec_ids.py

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -147,9 +147,11 @@ def scan_ids_in_branch(branch, prefix, scan_dirs, cwd=None):
147147
"""1つのブランチから指定プレフィックスの ID を抽出する。
148148
149149
Returns:
150-
list[tuple[str, str]]: (id_string, branch_name) のリスト
150+
list[tuple[str, str, str]]: (id_string, branch_name, file_path) のリスト
151151
"""
152-
pattern = re.compile(re.escape(prefix) + r'-(\d+)')
152+
# `(?<![A-Za-z0-9-])`: prefix の直前に境界を設け、`COMMON-DES-001` のような
153+
# 別名前空間の ID を `DES-001` として誤抽出しない(Issue #181 で検出)
154+
pattern = re.compile(r'(?<![A-Za-z0-9-])' + re.escape(prefix) + r'-(\d+)')
153155
results = []
154156

155157
unique_prefixes = sorted(set(
@@ -173,25 +175,50 @@ def scan_ids_in_branch(branch, prefix, scan_dirs, cwd=None):
173175
match = pattern.search(filepath)
174176
if match:
175177
id_str = '{}-{}'.format(prefix, match.group(1))
176-
results.append((id_str, branch))
178+
results.append((id_str, branch, filepath))
177179

178180
return results
179181

180182

181-
def find_duplicates(id_branch_pairs):
182-
"""異なるブランチで同じ ID が使用されているケースを検出する。"""
183-
id_to_branches = {}
184-
for id_str, branch in id_branch_pairs:
185-
if id_str not in id_to_branches:
186-
id_to_branches[id_str] = set()
187-
id_to_branches[id_str].add(branch)
183+
def find_duplicates(id_entries, shared_numbering=False):
184+
"""異なる文書が同じ ID(共有採番モードでは同じ番号)を主張しているケースを検出する。
185+
186+
同一ファイルパスが複数ブランチに存在するのは同じコミット履歴に由来する
187+
正常な状態であり、重複として扱わない(Issue #181)。重複と見なすのは、
188+
同じ番号キーを **異なるファイルパス** が主張している場合のみ。
189+
190+
Args:
191+
id_entries: (id_str, branch, file_path) のリスト
192+
shared_numbering: True の場合、prefix を無視して番号のみをキーに衝突を見る
193+
(--share-prefixes 指定時。`ADR-032` と `DES-032` は共有番号 032 の衝突として報告する)
194+
195+
Returns:
196+
list[dict]: 各要素は {'id', 'ids', 'branches', 'paths'}。
197+
'id' は代表 ID(後方互換)、'ids' は衝突に関与する全 ID。
198+
"""
199+
id_num_pattern = re.compile(r'^(.+)-(\d+)$')
200+
groups = {}
201+
for id_str, branch, path in id_entries:
202+
m = id_num_pattern.match(id_str)
203+
if not m:
204+
continue
205+
prefix, num = m.group(1), int(m.group(2))
206+
key = num if shared_numbering else (prefix, num)
207+
group = groups.setdefault(key, {'ids': set(), 'branches': set(), 'paths': set()})
208+
group['ids'].add(id_str)
209+
group['branches'].add(branch)
210+
group['paths'].add(path)
188211

189212
duplicates = []
190-
for id_str, branches in sorted(id_to_branches.items()):
191-
if len(branches) > 1:
213+
for key in sorted(groups):
214+
group = groups[key]
215+
if len(group['paths']) > 1:
216+
ids = sorted(group['ids'])
192217
duplicates.append({
193-
'id': id_str,
194-
'branches': sorted(branches),
218+
'id': ids[0],
219+
'ids': ids,
220+
'branches': sorted(group['branches']),
221+
'paths': sorted(group['paths']),
195222
})
196223
return duplicates
197224

@@ -232,7 +259,7 @@ def scan_spec_ids(prefix, project_root, doc_structure_path=None, cwd=None, share
232259
max_number = 0
233260
unique_ids = set()
234261

235-
for id_str, _ in all_pairs:
262+
for id_str, _, _ in all_pairs:
236263
unique_ids.add(id_str)
237264
m = number_pattern.match(id_str)
238265
if m:
@@ -243,7 +270,7 @@ def scan_spec_ids(prefix, project_root, doc_structure_path=None, cwd=None, share
243270
next_number = max_number + 1
244271
next_id = '{}-{:03d}'.format(prefix, next_number)
245272

246-
duplicates = find_duplicates(all_pairs)
273+
duplicates = find_duplicates(all_pairs, shared_numbering=bool(share_prefixes))
247274

248275
result = {
249276
'status': 'ok',

plugins/forge/skills/start-design/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ SCAN_SCRIPT="${CLAUDE_PLUGIN_ROOT}/skills/next-spec-id/scripts/scan_spec_ids.py"
226226
python3 "$SCAN_SCRIPT" DES --share-prefixes ADR,DES
227227
```
228228

229-
JSON 出力の `next_id` をファイル名・設計 ID として使用する。`duplicates` が空でない場合は警告を表示する。
229+
JSON 出力の `next_id` をファイル名・設計 ID として使用する。`duplicates` が空でない場合は警告を表示する`duplicates` には「異なるファイルが同じ ID / 共有番号を主張している」実際の衝突のみが報告される。同一履歴由来の複数ブランチ出現はノイズとして除外済みのため、空でなければ必ずユーザーに提示する)
230230

231231
**ADR(アーキテクチャ決定記録)を作成する場合 [MANDATORY]**: 設計判断の根拠を ADR として新規作成する際も、ADR の ID は手動で決定せず、必ず `next-spec-id` で採番する(プレフィックスは `ADR`)。手動採番は並行ブランチでの番号衝突(同一 `ADR-NNN` が別内容で重複)の原因になる。ADR と DES は同一ディレクトリで通し番号を共有するため、必ず `--share-prefixes ADR,DES` を付与する:
232232

tests/forge/scripts/test_scan_spec_ids.py

Lines changed: 153 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -67,45 +67,97 @@ class TestFindDuplicates(unittest.TestCase):
6767
"""find_duplicates のテスト"""
6868

6969
def test_no_duplicates(self):
70-
pairs = [
71-
('SCR-001', 'main'),
72-
('SCR-002', 'main'),
73-
('SCR-003', 'feature/foo'),
70+
entries = [
71+
('SCR-001', 'main', 'specs/SCR-001_a.md'),
72+
('SCR-002', 'main', 'specs/SCR-002_b.md'),
73+
('SCR-003', 'feature/foo', 'specs/SCR-003_c.md'),
7474
]
75-
self.assertEqual(find_duplicates(pairs), [])
75+
self.assertEqual(find_duplicates(entries), [])
7676

7777
def test_same_id_same_branch(self):
78-
pairs = [
79-
('SCR-001', 'main'),
80-
('SCR-001', 'main'),
78+
entries = [
79+
('SCR-001', 'main', 'specs/SCR-001_a.md'),
80+
('SCR-001', 'main', 'specs/SCR-001_a.md'),
8181
]
82-
self.assertEqual(find_duplicates(pairs), [])
82+
self.assertEqual(find_duplicates(entries), [])
83+
84+
def test_same_file_on_multiple_branches_is_not_duplicate(self):
85+
"""Issue #181: 同一履歴由来(同一パス)の ID が複数ブランチに見えるだけの
86+
ケースは duplicate として報告しない"""
87+
entries = [
88+
('SCR-001', 'develop', 'specs/SCR-001_a.md'),
89+
('SCR-001', 'feature/foo', 'specs/SCR-001_a.md'),
90+
('SCR-001', 'origin/develop', 'specs/SCR-001_a.md'),
91+
]
92+
self.assertEqual(find_duplicates(entries), [])
8393

8494
def test_duplicate_across_branches(self):
85-
pairs = [
86-
('SCR-001', 'main'),
87-
('SCR-001', 'feature/foo'),
88-
('SCR-002', 'main'),
95+
"""異なるファイルが同じ ID を主張する場合は duplicate として報告する"""
96+
entries = [
97+
('SCR-001', 'main', 'specs/SCR-001_a.md'),
98+
('SCR-001', 'feature/foo', 'specs/SCR-001_x.md'),
99+
('SCR-002', 'main', 'specs/SCR-002_b.md'),
89100
]
90-
result = find_duplicates(pairs)
101+
result = find_duplicates(entries)
91102
self.assertEqual(len(result), 1)
92103
self.assertEqual(result[0]['id'], 'SCR-001')
93104
self.assertIn('main', result[0]['branches'])
94105
self.assertIn('feature/foo', result[0]['branches'])
106+
self.assertEqual(
107+
result[0]['paths'],
108+
['specs/SCR-001_a.md', 'specs/SCR-001_x.md'],
109+
)
95110

96111
def test_multiple_duplicates(self):
97-
pairs = [
98-
('SCR-013', 'feature/a'),
99-
('SCR-013', 'origin/feature/b'),
100-
('SCR-014', 'feature/a'),
101-
('SCR-014', 'origin/feature/b'),
112+
entries = [
113+
('SCR-013', 'feature/a', 'specs/SCR-013_a.md'),
114+
('SCR-013', 'origin/feature/b', 'specs/SCR-013_b.md'),
115+
('SCR-014', 'feature/a', 'specs/SCR-014_a.md'),
116+
('SCR-014', 'origin/feature/b', 'specs/SCR-014_b.md'),
102117
]
103-
result = find_duplicates(pairs)
118+
result = find_duplicates(entries)
104119
self.assertEqual(len(result), 2)
105120
ids = [d['id'] for d in result]
106121
self.assertIn('SCR-013', ids)
107122
self.assertIn('SCR-014', ids)
108123

124+
def test_shared_numbering_detects_cross_prefix_collision(self):
125+
"""Issue #181: 共有採番モードでは ADR-032 と DES-032 を共有番号 032 の
126+
衝突として検出する"""
127+
entries = [
128+
('ADR-032', 'develop', 'specs/design/ADR-032_foo.md'),
129+
('DES-032', 'feature/x', 'specs/design/DES-032_bar.md'),
130+
]
131+
result = find_duplicates(entries, shared_numbering=True)
132+
self.assertEqual(len(result), 1)
133+
self.assertEqual(result[0]['ids'], ['ADR-032', 'DES-032'])
134+
135+
def test_shared_numbering_cross_prefix_not_collision_without_flag(self):
136+
"""共有採番モードでなければ ADR-032 と DES-032 は独立した ID として扱う"""
137+
entries = [
138+
('ADR-032', 'develop', 'specs/design/ADR-032_foo.md'),
139+
('DES-032', 'feature/x', 'specs/design/DES-032_bar.md'),
140+
]
141+
self.assertEqual(find_duplicates(entries), [])
142+
143+
def test_shared_numbering_same_file_not_duplicate(self):
144+
"""共有採番モードでも同一パス由来は duplicate にならない"""
145+
entries = [
146+
('ADR-032', 'develop', 'specs/design/ADR-032_foo.md'),
147+
('ADR-032', 'origin/develop', 'specs/design/ADR-032_foo.md'),
148+
]
149+
self.assertEqual(find_duplicates(entries, shared_numbering=True), [])
150+
151+
def test_zero_padding_variants_group_by_number(self):
152+
"""番号は int 正規化して比較する(DES-032 と DES-32 は同じ番号の衝突)"""
153+
entries = [
154+
('DES-032', 'develop', 'specs/design/DES-032_foo.md'),
155+
('DES-32', 'feature/x', 'specs/design/DES-32_bar.md'),
156+
]
157+
result = find_duplicates(entries)
158+
self.assertEqual(len(result), 1)
159+
self.assertEqual(result[0]['ids'], ['DES-032', 'DES-32'])
160+
109161
def test_empty_input(self):
110162
self.assertEqual(find_duplicates([]), [])
111163

@@ -212,6 +264,23 @@ def test_glob_dirs_collapsed(self, mock_git):
212264
self.assertEqual(len(result), 1)
213265
self.assertEqual(result[0][0], 'SCR-001')
214266

267+
@patch('scan_spec_ids._run_git')
268+
def test_namespaced_prefix_not_extracted(self, mock_git):
269+
"""Issue #181: COMMON-DES-001 のような別名前空間の ID を
270+
DES として誤抽出しない(部分文字列マッチの防止)"""
271+
mock_git.return_value = (
272+
'docs/specs/common/design/COMMON-DES-001_skill_base.md\n'
273+
'docs/specs/forge/design/DES-001_query_abstraction.md'
274+
)
275+
result = scan_ids_in_branch(
276+
'main', 'DES', ['docs/specs/'], cwd='/tmp'
277+
)
278+
self.assertEqual(len(result), 1)
279+
self.assertEqual(result[0][0], 'DES-001')
280+
self.assertEqual(
281+
result[0][2], 'docs/specs/forge/design/DES-001_query_abstraction.md'
282+
)
283+
215284
@patch('scan_spec_ids._run_git')
216285
def test_zero_padded_ids(self, mock_git):
217286
mock_git.return_value = (
@@ -267,9 +336,9 @@ def git_side_effect(*args, cwd=None):
267336
self.assertEqual(result['base_branch'], 'main')
268337
self.assertEqual(result['branches_scanned'], 2)
269338
self.assertEqual(result['ids_found'], 4)
270-
# SCR-001 exists on both main and feature/foo (inherited via branch)
271-
self.assertEqual(len(result['duplicates']), 1)
272-
self.assertEqual(result['duplicates'][0]['id'], 'SCR-001')
339+
# SCR-001 は同一パス specs/SCR-001_a.md が両ブランチに存在するだけ
340+
# (同一履歴由来)なので duplicate ではない(Issue #181)
341+
self.assertEqual(result['duplicates'], [])
273342

274343
@patch('scan_spec_ids.get_scan_branches')
275344
@patch('scan_spec_ids.detect_base_branch')
@@ -431,6 +500,67 @@ def git_side_effect(*args, cwd=None):
431500
self.assertEqual(result['max_number'], 32)
432501
self.assertEqual(result['shared_with'], ['DES'])
433502

503+
@patch('scan_spec_ids.get_scan_branches')
504+
@patch('scan_spec_ids.detect_base_branch')
505+
@patch('scan_spec_ids._run_git')
506+
@patch('scan_spec_ids.get_specs_root_dirs')
507+
def test_share_prefixes_detects_shared_number_collision(
508+
self, mock_dirs, mock_git, mock_base, mock_branches
509+
):
510+
"""Issue #181 受け入れ基準: ADR-032_foo.md と DES-032_bar.md がある場合、
511+
--share-prefixes ADR,DES で共有番号衝突が検出される"""
512+
mock_dirs.return_value = ['specs/design/']
513+
mock_base.return_value = 'develop'
514+
mock_branches.return_value = ['develop']
515+
516+
def git_side_effect(*args, cwd=None):
517+
if args[0] == 'ls-tree':
518+
return (
519+
'specs/design/ADR-032_foo.md\n'
520+
'specs/design/DES-032_bar.md'
521+
)
522+
return ''
523+
mock_git.side_effect = git_side_effect
524+
525+
result = scan_spec_ids(
526+
'DES', '/tmp/project', cwd='/tmp/project',
527+
share_prefixes=['ADR', 'DES'],
528+
)
529+
530+
self.assertEqual(len(result['duplicates']), 1)
531+
self.assertEqual(result['duplicates'][0]['ids'], ['ADR-032', 'DES-032'])
532+
533+
@patch('scan_spec_ids.get_scan_branches')
534+
@patch('scan_spec_ids.detect_base_branch')
535+
@patch('scan_spec_ids._run_git')
536+
@patch('scan_spec_ids.get_specs_root_dirs')
537+
def test_share_prefixes_same_history_ids_not_reported(
538+
self, mock_dirs, mock_git, mock_base, mock_branches
539+
):
540+
"""Issue #181 受け入れ基準: develop と current branch に同一ファイル由来で
541+
存在する既存 ID は duplicate 警告にならない"""
542+
mock_dirs.return_value = ['specs/design/']
543+
mock_base.return_value = 'develop'
544+
mock_branches.return_value = ['develop', 'feature/work', 'origin/develop']
545+
546+
def git_side_effect(*args, cwd=None):
547+
if args[0] == 'ls-tree':
548+
# 全ブランチに同一パスで存在(同一履歴由来)
549+
return (
550+
'specs/design/ADR-032_foo.md\n'
551+
'specs/design/DES-031_bar.md'
552+
)
553+
return ''
554+
mock_git.side_effect = git_side_effect
555+
556+
result = scan_spec_ids(
557+
'DES', '/tmp/project', cwd='/tmp/project',
558+
share_prefixes=['ADR', 'DES'],
559+
)
560+
561+
self.assertEqual(result['duplicates'], [])
562+
self.assertEqual(result['next_id'], 'DES-033')
563+
434564
@patch('scan_spec_ids.get_scan_branches')
435565
@patch('scan_spec_ids.detect_base_branch')
436566
@patch('scan_spec_ids._run_git')

0 commit comments

Comments
 (0)