@@ -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