@@ -92,76 +92,50 @@ describe("isDuplicateClusterWinnerByClaim (#dup-winner claim election)", () => {
9292 } ) ;
9393} ) ;
9494
95- describe ( "isDuplicateClusterWinnerByClaim createdAt precedence (#dup-winner true-creation-time )" , ( ) => {
95+ describe ( "isDuplicateClusterWinnerByClaim claim-time election with createdAt present (#dup-winner anti-backdating )" , ( ) => {
9696 const member = ( number : number , createdAt : string | null , linkedIssueClaimedAt : string | null ) => ( { number, createdAt, linkedIssueClaimedAt } ) ;
9797
98- it ( "REGRESSION: elects the PR that GitHub says opened first, even when gittensory OBSERVED (claimed) the later-opened sibling first" , ( ) => {
99- // PR 13 truly opened first (10:00) but gittensory's stalled sweep only got around to syncing/claiming it at
100- // 11:00. PR 14 opened later (10:05) but was claimed immediately (10:06) because the sweep happened to reach
101- // it first. Under the old claim-time-only rule, 14 would wrongly win and 13 (the real first mover) would be
102- // closed as the "duplicate." createdAt must override that.
98+ it ( "REGRESSION: does not let an older placeholder PR steal winner credit by adding the issue later" , ( ) => {
99+ // PR 12 was opened first but only edited in the linked issue after PR 13 had already claimed it. The
100+ // anti-backdating signal is linkedIssueClaimedAt, so createdAt must not override the actual claim order.
103101 expect (
104102 isDuplicateClusterWinnerByClaim (
105- member ( 13 , "2026-06-29T10:00:00.000Z" , "2026-06-29T11:00:00.000Z" ) ,
106- [ member ( 14 , "2026-06-29T10:05:00.000Z" , "2026-06-29T10:06:00.000Z" ) ] ,
107- ) ,
108- ) . toBe ( true ) ;
109- // And symmetrically, the later-created PR no longer wins just because it was claimed first.
110- expect (
111- isDuplicateClusterWinnerByClaim (
112- member ( 14 , "2026-06-29T10:05:00.000Z" , "2026-06-29T10:06:00.000Z" ) ,
113- [ member ( 13 , "2026-06-29T10:00:00.000Z" , "2026-06-29T11:00:00.000Z" ) ] ,
103+ member ( 12 , "2026-06-29T09:00:00.000Z" , "2026-06-29T10:05:00.000Z" ) ,
104+ [ member ( 13 , "2026-06-29T09:30:00.000Z" , "2026-06-29T10:00:00.000Z" ) ] ,
114105 ) ,
115106 ) . toBe ( false ) ;
116- } ) ;
117-
118- it ( "falls back to claim-time comparison when only ONE side has a valid createdAt (mixed legacy/modern cluster)" , ( ) => {
119- // pr has createdAt; sibling (a legacy row) does not — never mix clocks across the two sides of one
120- // comparison. pr's claim (10:00) is earlier than sibling's claim (10:05) ⇒ pr still wins via the fallback.
121107 expect (
122108 isDuplicateClusterWinnerByClaim (
123- { number : 12 , createdAt : "2026-06-29T09:00 :00.000Z" , linkedIssueClaimedAt : "2026-06-29T10:00:00.000Z" } ,
124- [ { number : 13 , createdAt : null , linkedIssueClaimedAt : "2026-06-29T10:05:00.000Z" } ] ,
109+ member ( 13 , "2026-06-29T09:30 :00.000Z" , "2026-06-29T10:00:00.000Z" ) ,
110+ [ member ( 12 , "2026-06-29T09:00:00.000Z" , "2026-06-29T10:05:00.000Z" ) ] ,
125111 ) ,
126112 ) . toBe ( true ) ;
127- // Same mixed case, but pr's own claim is later than the sibling's ⇒ pr loses via the fallback.
128- expect (
129- isDuplicateClusterWinnerByClaim (
130- { number : 12 , createdAt : "2026-06-29T09:00:00.000Z" , linkedIssueClaimedAt : "2026-06-29T10:05:00.000Z" } ,
131- [ { number : 13 , createdAt : null , linkedIssueClaimedAt : "2026-06-29T10:00:00.000Z" } ] ,
132- ) ,
133- ) . toBe ( false ) ;
134113 } ) ;
135114
136- it ( "falls back to claim-time comparison when a createdAt value is present but unparseable " , ( ) => {
115+ it ( "ignores createdAt even when both sides have valid values " , ( ) => {
137116 expect (
138117 isDuplicateClusterWinnerByClaim (
139- member ( 12 , "not-a-date " , "2026-06-29T10:00:00.000Z" ) ,
140- [ member ( 13 , "2026-06-29T09 :00:00.000Z" , "2026-06-29T10:05 :00.000Z" ) ] ,
118+ member ( 14 , "2026-06-29T10:05:00.000Z " , "2026-06-29T10:00:00.000Z" ) ,
119+ [ member ( 13 , "2026-06-29T10 :00:00.000Z" , "2026-06-29T11:00 :00.000Z" ) ] ,
141120 ) ,
142121 ) . toBe ( true ) ;
143122 } ) ;
144123
145- it ( "tie-breaks equal createdAt values by PR number, mirroring the claim-time tie-break" , ( ) => {
146- expect ( isDuplicateClusterWinnerByClaim ( member ( 12 , "2026-06-29T10:00:00.000Z" , null ) , [ member ( 13 , "2026-06-29T10:00:00.000Z" , null ) ] ) ) . toBe ( true ) ;
147- expect ( isDuplicateClusterWinnerByClaim ( member ( 13 , "2026-06-29T10:00:00.000Z" , null ) , [ member ( 12 , "2026-06-29T10:00:00.000Z" , null ) ] ) ) . toBe ( false ) ;
148- } ) ;
149-
150- it ( "createdAt-based cases are unaffected by (and do not require) a claim timestamp at all" , ( ) => {
151- expect ( isDuplicateClusterWinnerByClaim ( member ( 12 , "2026-06-29T10:00:00.000Z" , null ) , [ member ( 13 , "2026-06-29T10:05:00.000Z" , null ) ] ) ) . toBe ( true ) ;
124+ it ( "still fails closed when createdAt is present but claim timing is missing" , ( ) => {
125+ expect ( isDuplicateClusterWinnerByClaim ( member ( 12 , "2026-06-29T10:00:00.000Z" , null ) , [ member ( 13 , "2026-06-29T10:05:00.000Z" , null ) ] ) ) . toBe ( false ) ;
152126 } ) ;
153127} ) ;
154128
155129describe ( "resolveDuplicateClusterWinnerNumber (#dup-winner-credit)" , ( ) => {
156130 it ( "returns this PR's own number when it is the winner" , ( ) => {
157- expect ( resolveDuplicateClusterWinnerNumber ( { number : 12 , createdAt : "2026-06-29T10:00:00.000Z" } , [ { number : 13 , createdAt : "2026-06-29T10:05:00.000Z" } ] ) ) . toBe ( 12 ) ;
131+ expect ( resolveDuplicateClusterWinnerNumber ( { number : 12 , linkedIssueClaimedAt : "2026-06-29T10:00:00.000Z" } , [ { number : 13 , linkedIssueClaimedAt : "2026-06-29T10:05:00.000Z" } ] ) ) . toBe ( 12 ) ;
158132 } ) ;
159133
160134 it ( "returns the actual winning sibling's number when this PR is a loser, even with multiple siblings" , ( ) => {
161135 expect (
162- resolveDuplicateClusterWinnerNumber ( { number : 14 , createdAt : "2026-06-29T10:10:00.000Z" } , [
163- { number : 13 , createdAt : "2026-06-29T10:00:00.000Z" } ,
164- { number : 15 , createdAt : "2026-06-29T10:05:00.000Z" } ,
136+ resolveDuplicateClusterWinnerNumber ( { number : 14 , linkedIssueClaimedAt : "2026-06-29T10:10:00.000Z" } , [
137+ { number : 13 , linkedIssueClaimedAt : "2026-06-29T10:00:00.000Z" } ,
138+ { number : 15 , linkedIssueClaimedAt : "2026-06-29T10:05:00.000Z" } ,
165139 ] ) ,
166140 ) . toBe ( 13 ) ;
167141 } ) ;
@@ -223,9 +197,9 @@ describe("dupWinnerLinkedDuplicateCount (#dup-winner close-reason seam)", () =>
223197 expect ( dupWinnerLinkedDuplicateCount ( [ ] , 12 , "2026-06-29T10:00:00.000Z" , false ) ) . toBe ( 0 ) ;
224198 } ) ;
225199
226- it ( "REGRESSION (#dup-winner true-creation-time ): createdAt overrides a claim-time-only verdict when passed through" , ( ) => {
227- // By claim time alone this PR (12) would lose to sibling 13 ( claimed earlier, 10:00 vs 10:05). But 12's true
228- // createdAt (09:00) precedes 13's (09:30), so passing createdAt flips the verdict to a win (count 0) .
200+ it ( "REGRESSION (#dup-winner anti-backdating ): createdAt does not override claim-time ordering when passed through" , ( ) => {
201+ // PR 12 is older, but sibling 13 claimed the linked issue first; passing createdAt must not suppress the
202+ // duplicate count for the later claimant .
229203 expect (
230204 dupWinnerLinkedDuplicateCount (
231205 [ { number : 13 , linkedIssueClaimedAt : "2026-06-29T10:00:00.000Z" , createdAt : "2026-06-29T09:30:00.000Z" } ] ,
@@ -234,21 +208,21 @@ describe("dupWinnerLinkedDuplicateCount (#dup-winner close-reason seam)", () =>
234208 true ,
235209 "2026-06-29T09:00:00.000Z" ,
236210 ) ,
237- ) . toBe ( 0 ) ;
211+ ) . toBe ( 1 ) ;
238212 } ) ;
239213} ) ;
240214
241215describe ( "dupWinnerLinkedDuplicateWinnerNumber (#dup-winner-credit close-reason naming seam)" , ( ) => {
242216 it ( "flag OFF ⇒ null regardless of who would win (generic wording, byte-identical to before this existed)" , ( ) => {
243- expect ( dupWinnerLinkedDuplicateWinnerNumber ( [ { number : 13 , createdAt : "2026-06-29T10:05:00.000Z" } ] , 12 , undefined , false , "2026-06-29T10:00:00.000Z" ) ) . toBeNull ( ) ;
217+ expect ( dupWinnerLinkedDuplicateWinnerNumber ( [ { number : 13 , linkedIssueClaimedAt : "2026-06-29T10:05:00.000Z" } ] , 12 , "2026-06-29T10:00:00.000Z" , false , "2026-06-29T10:00:00.000Z" ) ) . toBeNull ( ) ;
244218 } ) ;
245219
246220 it ( "winner + flag ON ⇒ null (nothing to name — its own close reason omits the duplicate cause entirely)" , ( ) => {
247- expect ( dupWinnerLinkedDuplicateWinnerNumber ( [ { number : 13 , createdAt : "2026-06-29T10:05:00.000Z" } ] , 12 , undefined , true , "2026-06-29T10:00:00.000Z" ) ) . toBeNull ( ) ;
221+ expect ( dupWinnerLinkedDuplicateWinnerNumber ( [ { number : 13 , linkedIssueClaimedAt : "2026-06-29T10:05:00.000Z" } ] , 12 , "2026-06-29T10:00:00.000Z" , true , "2026-06-29T10:00:00.000Z" ) ) . toBeNull ( ) ;
248222 } ) ;
249223
250224 it ( "loser + flag ON ⇒ the actual winning sibling's number" , ( ) => {
251- expect ( dupWinnerLinkedDuplicateWinnerNumber ( [ { number : 12 , createdAt : "2026-06-29T10:00:00.000Z" } ] , 14 , undefined , true , "2026-06-29T10:10:00.000Z" ) ) . toBe ( 12 ) ;
225+ expect ( dupWinnerLinkedDuplicateWinnerNumber ( [ { number : 12 , linkedIssueClaimedAt : "2026-06-29T10:00:00.000Z" } ] , 14 , "2026-06-29T10:10:00.000Z" , true , "2026-06-29T10:10:00.000Z" ) ) . toBe ( 12 ) ;
252226 } ) ;
253227
254228 it ( "loser + flag ON, but the election is too ambiguous ⇒ null (falls back to generic wording)" , ( ) => {
@@ -326,7 +300,7 @@ describe("listOtherOpenPullRequests ordering (#audit-3.9)", () => {
326300 } ) ;
327301} ) ;
328302
329- describe ( "upsertPullRequestFromGitHub createdAt threading (#dup-winner true-creation-time) " , ( ) => {
303+ describe ( "upsertPullRequestFromGitHub createdAt threading" , ( ) => {
330304 it ( "populates createdAt from GitHub's true pull_request.created_at on the IMMEDIATE upsert return, not just on a later DB round-trip" , async ( ) => {
331305 const env = createTestEnv ( ) ;
332306 const record = await upsertPullRequestFromGitHub ( env , "owner/repo" , {
0 commit comments