gogcli-mcp-gmail 2.22.0 → 2.23.0

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.
@@ -0,0 +1,995 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ originFromDraftId,
4
+ rootsOwnThread,
5
+ parseHeaders,
6
+ headerValue,
7
+ appleIdentitySignals,
8
+ normalizeMessageId,
9
+ messageIdsIn,
10
+ normalizeBodyLines,
11
+ bodySimilarity,
12
+ isQuotedBodyLine,
13
+ authoredBodyLines,
14
+ measureBodyAgreement,
15
+ normalizeFrom,
16
+ parseInternalDateMs,
17
+ evaluateForkPairing,
18
+ decodeBase64UrlText,
19
+ decodePartText,
20
+ bestBodyText,
21
+ diffBodyLines,
22
+ evaluateContentLoss,
23
+ unreadableSiblingCheck,
24
+ FORK_BODY_SIMILARITY_THRESHOLD,
25
+ FORK_MIN_SHARED_AUTHORED_LINES,
26
+ FORK_MIN_SHARED_AUTHORED_CHARS,
27
+ FORK_SIGNALS_THAT_NEVER_SUFFICE,
28
+ type DraftFacts,
29
+ } from '../../src/tools/gmail-extra.js';
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Tier 0 primitives — derivable from `gog gmail drafts list` alone (no spawns).
33
+ // ---------------------------------------------------------------------------
34
+
35
+ describe('originFromDraftId', () => {
36
+ it('classifies an `s:` id as non-api', () => {
37
+ expect(originFromDraftId('s:14092347734530621658')).toBe('non-api');
38
+ });
39
+
40
+ it('classifies an `r` id as api', () => {
41
+ expect(originFromDraftId('r4303011157206680397')).toBe('api');
42
+ });
43
+
44
+ // The bug this test exists to prevent: draft ids can be NEGATIVE, so any
45
+ // /^r\d/ test misclassifies a real API draft as non-api.
46
+ it('classifies a NEGATIVE api id as api, not non-api', () => {
47
+ expect(originFromDraftId('r-457330811034304502')).toBe('api');
48
+ });
49
+
50
+ it('never says "apple-mail" — that verdict needs a header, not a prefix', () => {
51
+ expect(originFromDraftId('s:1')).not.toBe('apple-mail');
52
+ });
53
+ });
54
+
55
+ describe('rootsOwnThread', () => {
56
+ it('is true when threadId equals messageId', () => {
57
+ expect(rootsOwnThread({ id: 'r1', messageId: 'abc', threadId: 'abc' })).toBe(true);
58
+ });
59
+
60
+ it('is false when the draft sits in an existing thread', () => {
61
+ expect(rootsOwnThread({ id: 'r1', messageId: 'abc', threadId: 'def' })).toBe(false);
62
+ });
63
+
64
+ it('is false — not true — when messageId is absent (undefined === undefined trap)', () => {
65
+ expect(rootsOwnThread({ id: 'r1', threadId: 'abc' })).toBe(false);
66
+ });
67
+
68
+ it('is false when threadId is absent', () => {
69
+ expect(rootsOwnThread({ id: 'r1', messageId: 'abc' })).toBe(false);
70
+ });
71
+ });
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Header parsing — Apple writes `Mime-Version`, the Gmail API writes
75
+ // `MIME-Version`, so every lookup must be case-insensitive.
76
+ // ---------------------------------------------------------------------------
77
+
78
+ describe('parseHeaders / headerValue', () => {
79
+ it('keys case-insensitively', () => {
80
+ const h = parseHeaders({ headers: [{ name: 'Mime-Version', value: '1.0 (1.0)' }] });
81
+ expect(headerValue(h, 'MIME-Version')).toBe('1.0 (1.0)');
82
+ });
83
+
84
+ it('keeps every value of a repeated header', () => {
85
+ const h = parseHeaders({ headers: [
86
+ { name: 'Received', value: 'one' },
87
+ { name: 'received', value: 'two' },
88
+ { name: 'RECEIVED' },
89
+ ] });
90
+ expect(h.get('received')).toEqual(['one', 'two', '']);
91
+ expect(headerValue(h, 'Received')).toBe('one');
92
+ });
93
+
94
+ it('tolerates a missing payload, a missing headers array, nameless and valueless headers', () => {
95
+ expect(parseHeaders(undefined).size).toBe(0);
96
+ expect(parseHeaders({}).size).toBe(0);
97
+ const h = parseHeaders({ headers: [{ value: 'orphan' }, { name: 'X-Empty' }] });
98
+ expect(h.size).toBe(1);
99
+ expect(headerValue(h, 'x-empty')).toBe('');
100
+ });
101
+
102
+ it('returns undefined for a header that is not present', () => {
103
+ expect(headerValue(parseHeaders({ headers: [] }), 'Subject')).toBeUndefined();
104
+ });
105
+ });
106
+
107
+ describe('appleIdentitySignals', () => {
108
+ it('reports X-Uniform-Type-Identifier only when it names an Apple type', () => {
109
+ expect(appleIdentitySignals([{ name: 'X-Uniform-Type-Identifier', value: 'com.apple.mail-draft' }]))
110
+ .toEqual(['X-Uniform-Type-Identifier: com.apple.mail-draft']);
111
+ expect(appleIdentitySignals([{ name: 'X-Uniform-Type-Identifier', value: 'org.example.thing' }]))
112
+ .toEqual([]);
113
+ });
114
+
115
+ it('reports X-Universally-Unique-Identifier and any X-Apple-* header', () => {
116
+ expect(appleIdentitySignals([
117
+ { name: 'X-Universally-Unique-Identifier', value: 'ABC-DEF' },
118
+ { name: 'X-Apple-Notify-Thread', value: 'yes' },
119
+ ])).toEqual([
120
+ 'X-Universally-Unique-Identifier: ABC-DEF',
121
+ 'X-Apple-Notify-Thread: yes',
122
+ ]);
123
+ });
124
+
125
+ it('ignores ordinary headers and tolerates missing name/value/list', () => {
126
+ expect(appleIdentitySignals([{ name: 'Subject', value: 'hi' }])).toEqual([]);
127
+ expect(appleIdentitySignals([{ value: 'nameless' }])).toEqual([]);
128
+ expect(appleIdentitySignals([{ name: 'X-Apple-Mail-Remote-Attachments' }])).toEqual(['X-Apple-Mail-Remote-Attachments: ']);
129
+ expect(appleIdentitySignals(undefined)).toEqual([]);
130
+ });
131
+ });
132
+
133
+ describe('normalizeMessageId / messageIdsIn', () => {
134
+ it('strips angle brackets and surrounding whitespace', () => {
135
+ expect(normalizeMessageId(' <ABC@gmail.com> ')).toBe('ABC@gmail.com');
136
+ expect(normalizeMessageId('ABC@gmail.com')).toBe('ABC@gmail.com');
137
+ });
138
+
139
+ it('returns undefined for absent or empty ids', () => {
140
+ expect(normalizeMessageId(undefined)).toBeUndefined();
141
+ expect(normalizeMessageId(' ')).toBeUndefined();
142
+ expect(normalizeMessageId('<>')).toBeUndefined();
143
+ });
144
+
145
+ it('splits a References chain into ids', () => {
146
+ expect(messageIdsIn('<a@x> <b@y>\r\n <c@z>')).toEqual(['a@x', 'b@y', 'c@z']);
147
+ });
148
+
149
+ it('returns [] for an absent References header and for one with no bracketed id', () => {
150
+ expect(messageIdsIn(undefined)).toEqual([]);
151
+ expect(messageIdsIn('garbage')).toEqual([]);
152
+ });
153
+ });
154
+
155
+ describe('normalizeBodyLines / bodySimilarity', () => {
156
+ it('normalizes CRLF, collapses whitespace and drops blank lines', () => {
157
+ expect(normalizeBodyLines('a b\r\n\r\n c \n')).toEqual(['a b', 'c']);
158
+ expect(normalizeBodyLines(undefined)).toEqual([]);
159
+ });
160
+
161
+ it('scores identical bodies 1 and disjoint bodies 0', () => {
162
+ expect(bodySimilarity('one\ntwo', 'one\ntwo')).toBe(1);
163
+ expect(bodySimilarity('one\ntwo', 'three\nfour')).toBe(0);
164
+ });
165
+
166
+ it('scores a dropped paragraph between 0 and 1', () => {
167
+ const s = bodySimilarity('one\ntwo\nthree', 'one\ntwo');
168
+ expect(s).toBeCloseTo(2 / 3, 5);
169
+ });
170
+
171
+ it('scores 0 when either side has no content (no evidence, not a match)', () => {
172
+ expect(bodySimilarity('', 'one')).toBe(0);
173
+ expect(bodySimilarity('one', '')).toBe(0);
174
+ });
175
+ });
176
+
177
+ describe('isQuotedBodyLine / authoredBodyLines / measureBodyAgreement', () => {
178
+ it('treats a `>` line, an attribution line and a forward separator as quoting apparatus', () => {
179
+ expect(isQuotedBodyLine('> she wrote this')).toBe(true);
180
+ expect(isQuotedBodyLine('On 1 May 2026, at 09:14, Co Parent <co@x.com> wrote:')).toBe(true);
181
+ expect(isQuotedBodyLine('On Fri, May 1, 2026 at 9:14 AM Co Parent <co@x.com> wrote:')).toBe(true);
182
+ expect(isQuotedBodyLine('-----Original Message-----')).toBe(true);
183
+ expect(isQuotedBodyLine('---------- Forwarded message ---------')).toBe(true);
184
+ });
185
+
186
+ it('does not mistake ordinary prose for quoting', () => {
187
+ expect(isQuotedBodyLine('On the whole I agree with that.')).toBe(false);
188
+ expect(isQuotedBodyLine('She wrote: bring the booster seat.')).toBe(false);
189
+ expect(isQuotedBodyLine('Pickup at six.')).toBe(false);
190
+ });
191
+
192
+ it('keeps only the lines a draft actually authored', () => {
193
+ expect(authoredBodyLines('mine one\n> theirs\nOn 1 May 2026, at 09:14, X <x@y> wrote:\nmine two'))
194
+ .toEqual(['mine one', 'mine two']);
195
+ expect(authoredBodyLines(undefined)).toEqual([]);
196
+ });
197
+
198
+ it('measures agreement over authored lines only, and reports every input to that judgement', () => {
199
+ const a = 'The handoff moves to the 14th at six.\nI will bring the booster seat.\n> quoted\n> quoted two';
200
+ const b = 'The handoff moves to the 14th at six.\nI will bring the booster seat.\nAlso the swim bag.\n> quoted\n> quoted two';
201
+ const m = measureBodyAgreement(a, b);
202
+ expect(m.similarity).toBeCloseTo(2 / 3, 5);
203
+ expect(m.sharedAuthoredLines).toBe(2);
204
+ expect(m.sharedAuthoredChars).toBe(37 + 30);
205
+ expect(m.quotedLinesIgnored).toEqual({ original: 2, candidate: 2 });
206
+ expect(m.meetsThreshold).toBe(true);
207
+ expect(m.basisNote).toMatch(/quoted/i);
208
+ });
209
+
210
+ it('scores 0 when one side has no authored line at all', () => {
211
+ expect(measureBodyAgreement('> all quoted', 'real text here').similarity).toBe(0);
212
+ expect(measureBodyAgreement('real text here', '> all quoted').similarity).toBe(0);
213
+ });
214
+ });
215
+
216
+ describe('normalizeFrom', () => {
217
+ it('extracts and lowercases the address from a display-name form', () => {
218
+ expect(normalizeFrom('Chris Hall <Chris.C.Hall@Gmail.com>')).toBe('chris.c.hall@gmail.com');
219
+ });
220
+
221
+ it('accepts a bare address and rejects empty/absent input', () => {
222
+ expect(normalizeFrom(' A@B.com ')).toBe('a@b.com');
223
+ expect(normalizeFrom(' ')).toBeUndefined();
224
+ expect(normalizeFrom(undefined)).toBeUndefined();
225
+ });
226
+ });
227
+
228
+ describe('parseInternalDateMs', () => {
229
+ it('parses an epoch-millis string', () => {
230
+ expect(parseInternalDateMs('1754700000000')).toBe(1754700000000);
231
+ });
232
+
233
+ it('returns undefined for absent, blank or non-numeric values', () => {
234
+ expect(parseInternalDateMs(undefined)).toBeUndefined();
235
+ expect(parseInternalDateMs(' ')).toBeUndefined();
236
+ expect(parseInternalDateMs('yesterday')).toBeUndefined();
237
+ });
238
+ });
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // HAZARD A — the pairing verdict. A wrong "X replaced Y" sends the wrong text
242
+ // to a thread with a parenting coordinator on Cc. Precision over recall.
243
+ // ---------------------------------------------------------------------------
244
+
245
+ const ORIGINAL: DraftFacts = {
246
+ draftId: 'r4303011157206680397',
247
+ messageIdHeader: '<CAF=orig@mail.gmail.com>',
248
+ inReplyTo: '<coparent-1@mail.gmail.com>',
249
+ references: '<coparent-1@mail.gmail.com>',
250
+ from: 'Chris Hall <chris.c.hall@gmail.com>',
251
+ subject: 'Re: Schedule change',
252
+ internalDate: '1754700000000',
253
+ bodyText: 'Thanks for the note, that works on my end.\nI can do the 14th and the Friday after.\nPickup at six as usual.',
254
+ };
255
+
256
+ const APPLE_FORK: DraftFacts = {
257
+ draftId: 's:14092347734530621658',
258
+ messageIdHeader: '<9F3A1C2E-0000-4A11-BB00-1122334455AA@gmail.com>',
259
+ references: '<coparent-1@mail.gmail.com> <CAF=orig@mail.gmail.com>',
260
+ from: 'Chris Hall <chris.c.hall@gmail.com>',
261
+ subject: 'Re: Schedule change',
262
+ internalDate: '1754703600000',
263
+ bodyText: 'Thanks for the note, that works on my end.\nPickup at six as usual.',
264
+ appleSignals: ['X-Uniform-Type-Identifier: com.apple.mail-draft'],
265
+ };
266
+
267
+ describe('evaluateForkPairing — detection', () => {
268
+ it('confirms only with identity + lineage + ordering + same From', () => {
269
+ const r = evaluateForkPairing(ORIGINAL, APPLE_FORK, 2);
270
+ expect(r.verdict).toBe('confirmed');
271
+ expect(r.missing).toEqual([]);
272
+ expect(r.evidence.join(' | ')).toContain('CAF=orig@mail.gmail.com');
273
+ expect(r.note).toContain('s:14092347734530621658');
274
+ });
275
+
276
+ it('reports every number the lineage decision was made on, not just the outcome', () => {
277
+ const r = evaluateForkPairing(ORIGINAL, APPLE_FORK, 2);
278
+ expect(r.bodyAgreement.similarityThreshold).toBe(FORK_BODY_SIMILARITY_THRESHOLD);
279
+ expect(r.bodyAgreement.similarity).toBeCloseTo(2 / 3, 5);
280
+ expect(r.bodyAgreement.sharedAuthoredLines).toBe(2);
281
+ expect(r.bodyAgreement.minSharedAuthoredLines).toBe(FORK_MIN_SHARED_AUTHORED_LINES);
282
+ expect(r.bodyAgreement.sharedAuthoredChars).toBe(65);
283
+ expect(r.bodyAgreement.minSharedAuthoredChars).toBe(FORK_MIN_SHARED_AUTHORED_CHARS);
284
+ expect(r.bodyAgreement.quotedLinesIgnored).toEqual({ original: 0, candidate: 0 });
285
+ });
286
+
287
+ it('finds lineage from body similarity alone when no headers link the two', () => {
288
+ const rewritten: DraftFacts = {
289
+ ...APPLE_FORK,
290
+ references: undefined,
291
+ inReplyTo: undefined,
292
+ bodyText: ORIGINAL.bodyText,
293
+ };
294
+ const bare: DraftFacts = { ...ORIGINAL, inReplyTo: undefined, references: undefined };
295
+ const r = evaluateForkPairing(bare, rewritten, 2);
296
+ expect(r.verdict).toBe('confirmed');
297
+ expect(r.evidence.join(' | ')).toMatch(/similarity/i);
298
+ });
299
+ });
300
+
301
+ describe('evaluateForkPairing — HAZARD A: no false positives', () => {
302
+ // The mailbox holds deliberate [VERSION A] / [VERSION B] drafts created
303
+ // seconds apart: same subject, same sender, Apple-authored, newer — and
304
+ // completely unrelated. A subject+recency rule fires here and is WRONG.
305
+ it('returns "none" for two unrelated same-subject drafts minutes apart', () => {
306
+ const versionA: DraftFacts = {
307
+ draftId: 'r1', messageIdHeader: '<a@mail.gmail.com>',
308
+ from: 'chris.c.hall@gmail.com', subject: 'Re: Schedule change',
309
+ internalDate: '1754700000000', bodyText: 'alpha alpha alpha',
310
+ };
311
+ const versionB: DraftFacts = {
312
+ draftId: 's:2', messageIdHeader: '<B0000000-0000-4000-8000-000000000000@gmail.com>',
313
+ from: 'chris.c.hall@gmail.com', subject: 'Re: Schedule change',
314
+ internalDate: '1754700060000', bodyText: 'beta beta beta',
315
+ appleSignals: ['X-Universally-Unique-Identifier: B0000000'],
316
+ };
317
+ const r = evaluateForkPairing(versionA, versionB, 2);
318
+ expect(r.verdict).toBe('none');
319
+ expect(r.missing.join(' | ')).toMatch(/lineage/i);
320
+ });
321
+
322
+ // THE COMPOSITE TRAP: `s:` prefix + threadId===messageId + Apple headers +
323
+ // newer + same From are ALL consequences of "Apple wrote this draft". None of
324
+ // them references the supposed original. Together they are still not a pair.
325
+ it('refuses to pair on origin+recency+identity+same-From without a lineage signal', () => {
326
+ const orphan: DraftFacts = {
327
+ draftId: 's:3', from: 'chris.c.hall@gmail.com', subject: 'Re: Schedule change',
328
+ internalDate: '1754999999999', bodyText: 'nothing in common at all',
329
+ appleSignals: ['X-Apple-Notify-Thread: 1'],
330
+ };
331
+ const r = evaluateForkPairing(ORIGINAL, orphan, 2);
332
+ expect(r.verdict).toBe('none');
333
+ expect(r.note).not.toMatch(/replaced/i);
334
+ });
335
+
336
+
337
+ // ------------------------------------------------------------------------
338
+ // THE SHARED-ROOT TRAP. This is the DEFAULT shape of a co-parenting mailbox:
339
+ // most drafts are replies into the same few threads, so two drafts sharing a
340
+ // reply root is the norm, not evidence. A shared root links each draft to a
341
+ // common ANCESTOR — it says nothing about the candidate coming from the
342
+ // ORIGINAL — while the other three signals (Apple headers, newer, same From)
343
+ // are free on every draft the owner composes in Apple Mail.
344
+ // ------------------------------------------------------------------------
345
+ it('never confirms on a shared reply root: two independent replies to the same co-parent message', () => {
346
+ const handoff: DraftFacts = {
347
+ draftId: 'r1', messageIdHeader: '<orig@mail.gmail.com>',
348
+ inReplyTo: '<coparent-2026-05-01@mail.gmail.com>',
349
+ references: '<coparent-2026-05-01@mail.gmail.com>',
350
+ from: 'Chris Hall <chris.c.hall@gmail.com>', subject: 'Re: July handoff',
351
+ internalDate: '1754700000000',
352
+ bodyText: 'Confirming the July handoff at six on the 14th.\nI will bring the booster seat.',
353
+ };
354
+ const orthodontist: DraftFacts = {
355
+ draftId: 's:2', messageIdHeader: '<9F3A1C2E-0000-4A11-BB00-1122334455AA@gmail.com>',
356
+ inReplyTo: '<coparent-2026-05-01@mail.gmail.com>',
357
+ references: '<coparent-2026-05-01@mail.gmail.com>',
358
+ from: 'chris.c.hall@gmail.com', subject: 'Re: orthodontist invoice',
359
+ internalDate: '1754703600000',
360
+ bodyText: 'The orthodontist invoice came to 240 dollars.\nI am splitting it per the parenting plan.',
361
+ appleSignals: ['X-Uniform-Type-Identifier: com.apple.mail-draft'],
362
+ };
363
+ const r = evaluateForkPairing(handoff, orthodontist, 2);
364
+ expect(r.verdict).not.toBe('confirmed');
365
+ expect(r.note).not.toMatch(/\breplaced\b/i);
366
+ expect(r.missing.join(' | ')).toMatch(/no lineage signal/i);
367
+ // The root is still REPORTED — it is real — but labelled as unable to pair.
368
+ expect(r.evidence.join(' | ')).toMatch(/CORROBORATING ONLY/);
369
+ expect(r.evidence.join(' | ')).toMatch(/common ANCESTOR/);
370
+ expect(r.bodyAgreement.similarity).toBe(0);
371
+ });
372
+
373
+ // ------------------------------------------------------------------------
374
+ // THE QUOTED-TEXT TRAP. Apple Mail quotes the original on reply by default,
375
+ // so two unrelated replies into one thread share a large identical block. A
376
+ // whole-body line metric scores that pair WELL above the threshold; the
377
+ // lineage metric must therefore look only at what neither draft quoted.
378
+ // ------------------------------------------------------------------------
379
+ it('never counts quoted text as agreement: two unrelated replies quoting the same original', () => {
380
+ const quote = Array.from({ length: 30 }, (_, i) => `> quoted line ${i} of the co-parent's message`).join('\n');
381
+ const attribution = 'On 1 May 2026, at 09:14, Co Parent <co@x.com> wrote:';
382
+ const tuition: DraftFacts = {
383
+ draftId: 'r1', messageIdHeader: '<orig@mail.gmail.com>',
384
+ from: 'chris.c.hall@gmail.com', subject: 'Re: tuition', internalDate: '1754700000000',
385
+ bodyText: `Tuition is due on the 5th.\nI paid the deposit already.\nLet me know either way.\n${attribution}\n${quote}`,
386
+ };
387
+ const passport: DraftFacts = {
388
+ draftId: 's:2', messageIdHeader: '<B0000000-0000-4000-8000-000000000000@gmail.com>',
389
+ from: 'chris.c.hall@gmail.com', subject: 'Re: passport', internalDate: '1754703600000',
390
+ bodyText: `The passport renewal needs both signatures.\nI booked the appointment for the 3rd.\nBring the birth certificate.\nWe also need the old passport.\nCall me if that does not work.\n${attribution}\n${quote}`,
391
+ appleSignals: ['X-Universally-Unique-Identifier: B0000000'],
392
+ };
393
+ // The OLD whole-body metric would have called this a match; that is the
394
+ // regression this test exists to pin.
395
+ expect(bodySimilarity(tuition.bodyText, passport.bodyText)).toBeGreaterThan(FORK_BODY_SIMILARITY_THRESHOLD);
396
+
397
+ const r = evaluateForkPairing(tuition, passport, 2);
398
+ expect(r.verdict).toBe('none');
399
+ expect(r.note).not.toMatch(/\breplaced\b/i);
400
+ expect(r.bodyAgreement.similarity).toBe(0);
401
+ // The attribution line counts as quoting apparatus, not authored text.
402
+ expect(r.bodyAgreement.quotedLinesIgnored).toEqual({ original: 31, candidate: 31 });
403
+ });
404
+
405
+ it('does not pair on a scrap of shared boilerplate below the shared-text minimums', () => {
406
+ const base = {
407
+ from: 'chris.c.hall@gmail.com', subject: 'Re: anything',
408
+ inReplyTo: undefined, references: undefined,
409
+ };
410
+ const a: DraftFacts = { ...base, draftId: 'r1', internalDate: '1', bodyText: 'Sounds good.\nSent from my iPhone' };
411
+ const b: DraftFacts = {
412
+ ...base, draftId: 's:2', internalDate: '2', bodyText: 'I will check.\nSent from my iPhone',
413
+ appleSignals: ['X-Apple-Notify-Thread: 1'],
414
+ };
415
+ const r = evaluateForkPairing(a, b, 2);
416
+ expect(r.verdict).toBe('none');
417
+ // `Sent from my iPhone` is Apple Mail's own default signature: a line the
418
+ // CLIENT writes on every message, so it is apparatus and never counted as
419
+ // shared authorship in the first place.
420
+ expect(r.bodyAgreement.sharedAuthoredLines).toBe(0);
421
+ expect(r.bodyAgreement.boilerplateLinesIgnored).toEqual({ original: 1, candidate: 1 });
422
+ expect(r.bodyAgreement.meetsThreshold).toBe(false);
423
+ });
424
+
425
+ it('does not pair two short drafts whose only shared lines are too little text to mean anything', () => {
426
+ const base = { from: 'me@x.com', subject: 'Re: anything' };
427
+ const a: DraftFacts = { ...base, draftId: 'r1', internalDate: '1', bodyText: 'Ok.\nWill do.' };
428
+ const b: DraftFacts = {
429
+ ...base, draftId: 's:2', internalDate: '2', bodyText: 'Ok.\nWill do.',
430
+ appleSignals: ['X-Apple-Notify-Thread: 1'],
431
+ };
432
+ const r = evaluateForkPairing(a, b, 2);
433
+ expect(r.bodyAgreement.similarity).toBe(1);
434
+ expect(r.bodyAgreement.sharedAuthoredLines).toBeGreaterThanOrEqual(FORK_MIN_SHARED_AUTHORED_LINES);
435
+ expect(r.bodyAgreement.sharedAuthoredChars).toBeLessThan(FORK_MIN_SHARED_AUTHORED_CHARS);
436
+ expect(r.bodyAgreement.meetsThreshold).toBe(false);
437
+ expect(r.verdict).toBe('none');
438
+ });
439
+
440
+ it('scores agreement 0 when either side has no authored text left after quoting is removed', () => {
441
+ const quoteOnly: DraftFacts = { draftId: 's:2', bodyText: '> every line here is quoted', from: 'me@x.com', internalDate: '2' };
442
+ const r = evaluateForkPairing(ORIGINAL, quoteOnly, 2);
443
+ expect(r.bodyAgreement.similarity).toBe(0);
444
+ expect(r.verdict).toBe('none');
445
+ });
446
+
447
+ // A shared root is not nothing — it is just not lineage. Reporting it as
448
+ // `none` ("nothing links them") would be its own overclaim, so it comes back
449
+ // as the WEAKEST possible answer, phrased as a question.
450
+ it('reports a shared root alone as an explicitly weak "candidate", never as a fork', () => {
451
+ const a: DraftFacts = {
452
+ draftId: 'r1', messageIdHeader: '<orig@x>', inReplyTo: '<root@x>', references: '<root@x>',
453
+ from: 'me@x.com', internalDate: '1', bodyText: 'alpha alpha alpha',
454
+ };
455
+ const b: DraftFacts = {
456
+ draftId: 's:2', messageIdHeader: '<uuid@x>', references: '<root@x>',
457
+ from: 'me@x.com', internalDate: '2', bodyText: 'beta beta beta',
458
+ appleSignals: ['X-Apple-Notify-Thread: 1'],
459
+ };
460
+ const r = evaluateForkPairing(a, b, 2);
461
+ expect(r.verdict).toBe('candidate');
462
+ expect(r.note).toMatch(/WEAK/);
463
+ expect(r.note).toMatch(/\?/);
464
+ expect(r.note).not.toMatch(/\breplaced\b/i);
465
+ expect(r.note).toMatch(/every reply in that thread/i);
466
+ });
467
+
468
+ // HAZARD A cuts both ways for WORDING: the line-based comparison cannot see
469
+ // through Apple's re-wrapping and smart quotes, so "none" must be reported as
470
+ // a failure to find evidence, never as proof the two drafts are unrelated.
471
+ it('states "none" as a failure to find evidence, not as proof of unrelatedness', () => {
472
+ const r = evaluateForkPairing(ORIGINAL, {
473
+ draftId: 's:9', from: 'chris.c.hall@gmail.com', internalDate: '1755000000000',
474
+ bodyText: 'nothing in common', appleSignals: ['X-Apple-Notify-Thread: 1'],
475
+ }, 2);
476
+ expect(r.verdict).toBe('none');
477
+ expect(r.note).toMatch(/re-?wrap/i);
478
+ expect(r.note).not.toMatch(/unrelated drafts that happen to look alike/i);
479
+ });
480
+
481
+ // STRUCTURAL GUARANTEE: `confirmed` requires an Apple identity header, and an
482
+ // identity header can only come from a tier-2 fetch. A tier-0/tier-1 caller
483
+ // that smuggles in signals is a bug, and must fail loudly rather than emit a
484
+ // confirmed verdict off free fields.
485
+ it('throws if Apple identity signals arrive below tier 2', () => {
486
+ expect(() => evaluateForkPairing(ORIGINAL, APPLE_FORK, 0)).toThrow(/tier 2/i);
487
+ expect(() => evaluateForkPairing(ORIGINAL, APPLE_FORK, 1)).toThrow(/tier 2/i);
488
+ });
489
+
490
+ it('can never reach "confirmed" from tier-0/tier-1 data', () => {
491
+ const tier0Fork: DraftFacts = { ...APPLE_FORK, appleSignals: undefined };
492
+ for (const tier of [0, 1] as const) {
493
+ const r = evaluateForkPairing(ORIGINAL, tier0Fork, tier);
494
+ expect(r.verdict).toBe('candidate');
495
+ expect(r.missing.join(' | ')).toMatch(/Apple identity header/i);
496
+ }
497
+ });
498
+ });
499
+
500
+ describe('evaluateForkPairing — "candidate" is interrogative and names what is missing', () => {
501
+ it('downgrades to candidate and names the absent signal when the candidate is older', () => {
502
+ const older: DraftFacts = { ...APPLE_FORK, internalDate: '1754000000000' };
503
+ const r = evaluateForkPairing(ORIGINAL, older, 2);
504
+ expect(r.verdict).toBe('candidate');
505
+ expect(r.missing.join(' | ')).toMatch(/not newer/i);
506
+ expect(r.note).toMatch(/\?/);
507
+ expect(r.note).not.toMatch(/\breplaced\b/i);
508
+ });
509
+
510
+ it('names a missing internalDate on either side rather than assuming an order', () => {
511
+ expect(evaluateForkPairing({ ...ORIGINAL, internalDate: undefined }, APPLE_FORK, 2).missing.join(' | '))
512
+ .toMatch(/internalDate/i);
513
+ expect(evaluateForkPairing(ORIGINAL, { ...APPLE_FORK, internalDate: undefined }, 2).missing.join(' | '))
514
+ .toMatch(/internalDate/i);
515
+ });
516
+
517
+ it('names a differing or missing From', () => {
518
+ const other = evaluateForkPairing(ORIGINAL, { ...APPLE_FORK, from: 'someone.else@example.com' }, 2);
519
+ expect(other.verdict).toBe('candidate');
520
+ expect(other.missing.join(' | ')).toMatch(/different From/i);
521
+
522
+ expect(evaluateForkPairing({ ...ORIGINAL, from: undefined }, APPLE_FORK, 2).missing.join(' | '))
523
+ .toMatch(/From missing/i);
524
+ expect(evaluateForkPairing(ORIGINAL, { ...APPLE_FORK, from: undefined }, 2).missing.join(' | '))
525
+ .toMatch(/From missing/i);
526
+ });
527
+
528
+ it('does not claim a Message-Id citation when the original has no Message-Id header', () => {
529
+ const r = evaluateForkPairing({ ...ORIGINAL, messageIdHeader: undefined }, APPLE_FORK, 2);
530
+ expect(r.evidence.join(' | ')).not.toMatch(/Message-Id/i);
531
+ // Still confirmed — but on the AUTHORED-TEXT agreement (2 shared lines the
532
+ // two drafts wrote rather than quoted), never on the shared reply root,
533
+ // which the test below pins as insufficient on its own.
534
+ expect(r.verdict).toBe('confirmed');
535
+ expect(r.evidence.join(' | ')).toMatch(/LINEAGE: the two drafts agree on text/);
536
+ });
537
+
538
+ it('falls back to a placeholder when a draft id is unknown', () => {
539
+ const r = evaluateForkPairing({ ...ORIGINAL, draftId: undefined }, { ...APPLE_FORK, draftId: undefined }, 2);
540
+ expect(r.note).toContain('(unknown id)');
541
+ });
542
+ });
543
+
544
+ describe('FORK_SIGNALS_THAT_NEVER_SUFFICE', () => {
545
+ it('enumerates, for tool descriptions, the signals that must never pair on their own', () => {
546
+ const joined = FORK_SIGNALS_THAT_NEVER_SUFFICE.join(' ');
547
+ expect(joined).toMatch(/s:/);
548
+ expect(joined).toMatch(/threadId/);
549
+ expect(joined).toMatch(/subject/i);
550
+ expect(joined).toMatch(/0\.50|coin flip/i);
551
+ // "Apple fork => threading lost" is FALSE as a general rule (a live fork
552
+ // was found carrying a 5-deep References chain); nothing here may say it.
553
+ expect(joined).not.toMatch(/threading is (always )?lost/i);
554
+ });
555
+ });
556
+
557
+ // ---------------------------------------------------------------------------
558
+ // BODY EXTRACTION. `gog gmail drafts get --json` hands back the raw Gmail
559
+ // payload — gog's own text renderer is not reachable over --json — so the
560
+ // wrapper walks the MIME tree itself. A body it silently fails to find would
561
+ // show up as a diff claiming a whole draft is empty, so every branch is pinned.
562
+ // ---------------------------------------------------------------------------
563
+ const b64url = (s: string) => Buffer.from(s, 'utf8').toString('base64url');
564
+
565
+ describe('decodeBase64UrlText', () => {
566
+ it('decodes base64url, including multi-byte UTF-8', () => {
567
+ expect(decodeBase64UrlText(b64url('caf\u00e9 \u2014 ok'))).toBe('caf\u00e9 \u2014 ok');
568
+ });
569
+
570
+ it('returns empty string for absent or undecodable data rather than throwing', () => {
571
+ expect(decodeBase64UrlText(undefined)).toBe('');
572
+ expect(decodeBase64UrlText('')).toBe('');
573
+ expect(decodeBase64UrlText('!!!!not base64!!!!')).toBe('');
574
+ });
575
+ });
576
+
577
+ describe('decodePartText — transfer encoding and charset', () => {
578
+ const bytes = (...b: number[]) => Buffer.from(Uint8Array.from(b)).toString('base64url');
579
+
580
+ it('decodes a windows-1252 body instead of replacing its smart quote', () => {
581
+ // 0x92 is a RIGHT SINGLE QUOTATION MARK in cp1252 and invalid UTF-8, so a
582
+ // plain UTF-8 decode turns "don’t" into "don�t".
583
+ expect(decodePartText({
584
+ mimeType: 'text/plain',
585
+ headers: [{ name: 'Content-Type', value: 'text/plain; charset=windows-1252' }],
586
+ body: { data: bytes(0x64, 0x6f, 0x6e, 0x92, 0x74) },
587
+ })).toBe('don’t');
588
+ });
589
+
590
+ it('decodes a part that really is still quoted-printable, soft breaks included', () => {
591
+ expect(decodePartText({
592
+ mimeType: 'text/plain',
593
+ headers: [{ name: 'Content-Transfer-Encoding', value: 'quoted-printable' }],
594
+ body: { data: b64url('It cost =E2=80=94 a lot=\r\nreally, and 100=3D100=zz=') },
595
+ })).toBe('It cost — a lotreally, and 100=100=zz=');
596
+ });
597
+
598
+ it('does NOT re-decode a body Gmail already decoded, even though the header still says quoted-printable', () => {
599
+ // Gmail hands back `body.data` already decoded while leaving the original
600
+ // Content-Transfer-Encoding header in place, so decoding on the header
601
+ // alone would rewrite "2+2=44" to "2+2D".
602
+ expect(decodePartText({
603
+ mimeType: 'text/plain',
604
+ headers: [{ name: 'Content-Transfer-Encoding', value: 'quoted-printable' }],
605
+ body: { data: b64url('2+2=44 and that is that') },
606
+ })).toBe('2+2=44 and that is that');
607
+ // Non-ASCII bytes prove it is decoded already: quoted-printable is 7-bit.
608
+ expect(decodePartText({
609
+ mimeType: 'text/plain',
610
+ headers: [{ name: 'Content-Transfer-Encoding', value: 'quoted-printable' }],
611
+ body: { data: b64url('café =E2=80=94') },
612
+ })).toBe('café =E2=80=94');
613
+ });
614
+
615
+ it('does NOT re-decode a base64 part — Gmail already did, and doing it twice destroys the body', () => {
616
+ expect(decodePartText({
617
+ mimeType: 'text/plain',
618
+ headers: [{ name: 'Content-Transfer-Encoding', value: 'base64' }],
619
+ body: { data: b64url('plain words here') },
620
+ })).toBe('plain words here');
621
+ });
622
+
623
+ it('falls back to windows-1252 when the part declares no charset at all', () => {
624
+ // No Content-Type header, bytes that are not valid UTF-8: the choice is
625
+ // between mangling the smart quote and reading it. gog would read it.
626
+ expect(decodePartText({ mimeType: 'text/plain', body: { data: bytes(0x64, 0x6f, 0x6e, 0x92, 0x74) } })).toBe('don’t');
627
+ });
628
+
629
+ it('falls back to windows-1252 when the declared charset means nothing to this runtime', () => {
630
+ expect(decodePartText({
631
+ mimeType: 'text/plain',
632
+ headers: [{ name: 'Content-Type', value: 'text/plain; charset=x-nonesuch-9000' }],
633
+ body: { data: bytes(0x64, 0x6f, 0x6e, 0x92, 0x74) },
634
+ })).toBe('don’t');
635
+ });
636
+
637
+ it('keeps a UTF-8 body that is merely LABELLED windows-1252 — Gmail transcodes and leaves the header', () => {
638
+ expect(decodePartText({
639
+ mimeType: 'text/plain',
640
+ headers: [{ name: 'Content-Type', value: 'text/plain; charset="windows-1252"' }],
641
+ body: { data: b64url('don’t — really') },
642
+ })).toBe('don’t — really');
643
+ });
644
+
645
+ it('returns empty string for a part with no data', () => {
646
+ expect(decodePartText({ mimeType: 'text/plain' })).toBe('');
647
+ });
648
+ });
649
+
650
+ describe('bestBodyText', () => {
651
+ it('returns empty string when there is no payload', () => {
652
+ expect(bestBodyText(undefined)).toBe('');
653
+ });
654
+
655
+ it('reads a flat text/plain body', () => {
656
+ expect(bestBodyText({ mimeType: 'text/plain', body: { data: b64url('hello') } })).toBe('hello');
657
+ });
658
+
659
+ it('prefers text/plain nested anywhere over text/html', () => {
660
+ expect(bestBodyText({
661
+ mimeType: 'multipart/alternative',
662
+ parts: [
663
+ { mimeType: 'text/html', body: { data: b64url('<p>rich</p>') } },
664
+ { mimeType: 'multipart/related', parts: [{ mimeType: 'text/plain', body: { data: b64url('plain') } }] },
665
+ ],
666
+ })).toBe('plain');
667
+ });
668
+
669
+ it('falls back to text/html when there is no plain part', () => {
670
+ expect(bestBodyText({ mimeType: 'multipart/alternative', parts: [{ mimeType: 'text/html', body: { data: b64url('<p>rich</p>') } }] }))
671
+ .toBe('<p>rich</p>');
672
+ });
673
+
674
+ it('skips attachment parts and keeps the FIRST body of each type', () => {
675
+ expect(bestBodyText({
676
+ mimeType: 'multipart/mixed',
677
+ parts: [
678
+ { mimeType: 'text/plain', filename: 'notes.txt', body: { data: b64url('ATTACHED, NOT THE BODY') } },
679
+ { mimeType: 'text/plain', body: { data: b64url('first') } },
680
+ { mimeType: 'text/plain', body: { data: b64url('second') } },
681
+ ],
682
+ })).toBe('first');
683
+ });
684
+
685
+ it('treats a part with no mimeType as its own (unusable) type', () => {
686
+ // A part with neither a mimeType nor a recognisable one must not be picked
687
+ // up as the body — it is keyed under '' and never matches plain or html.
688
+ expect(bestBodyText({ parts: [{ body: { data: b64url('mystery') } }, { mimeType: 'text/plain', body: { data: b64url('real') } }] }))
689
+ .toBe('real');
690
+ });
691
+
692
+ it('finds a part whose mimeType carries parameters (text/plain; charset="UTF-8")', () => {
693
+ expect(bestBodyText({
694
+ mimeType: 'multipart/alternative',
695
+ parts: [
696
+ { mimeType: 'text/html; charset="UTF-8"', body: { data: b64url('<p>rich</p>') } },
697
+ { mimeType: 'text/plain; charset="UTF-8"', body: { data: b64url('plain') } },
698
+ ],
699
+ })).toBe('plain');
700
+ });
701
+
702
+ it('returns empty string when nothing carries a recognised body', () => {
703
+ expect(bestBodyText({ mimeType: 'multipart/mixed', parts: [{ mimeType: 'application/pdf', body: { data: b64url('%PDF') }, filename: 'a.pdf' }] })).toBe('');
704
+ });
705
+ });
706
+
707
+ // ---------------------------------------------------------------------------
708
+ // THE DIVERGENCE REPORT. The point of the whole feature: in the observed case
709
+ // NEITHER copy was a superset, so recreating from either one alone lost work.
710
+ // ---------------------------------------------------------------------------
711
+ describe('diffBodyLines', () => {
712
+ it('reports two-way divergence as "neither is a superset"', () => {
713
+ const d = diffBodyLines('a\nb\nkept-only-in-A', 'a\nb\nkept-only-in-B', 200);
714
+ expect(d.onlyInA).toEqual(['kept-only-in-A']);
715
+ expect(d.onlyInB).toEqual(['kept-only-in-B']);
716
+ expect(d.sharedLineCount).toBe(2);
717
+ expect(d.neitherIsSuperset).toBe(true);
718
+ expect(d.truncated).toBe(false);
719
+ expect(d.note).toContain('NEITHER');
720
+ });
721
+
722
+ it('names a one-sided superset in each direction', () => {
723
+ expect(diffBodyLines('a\nb\nc', 'a\nb', 200).note).toContain('Draft A is a superset');
724
+ expect(diffBodyLines('a\nb', 'a\nb\nc', 200).note).toContain('Draft B is a superset');
725
+ });
726
+
727
+ it('calls whitespace-equal bodies identical', () => {
728
+ const d = diffBodyLines('a\n\n b ', 'a\nb', 200);
729
+ expect(d.neitherIsSuperset).toBe(false);
730
+ expect(d.note).toContain('identical');
731
+ expect(d.similarity).toBe(1);
732
+ });
733
+
734
+ // HAZARD A, in the diff: "every line of A is present in B" is an invitation
735
+ // to delete or overwrite A. It must never be said about a body that merely
736
+ // failed to parse — an unread body is not an empty one.
737
+ it('never claims containment when one side has no readable body', () => {
738
+ const a = diffBodyLines('', 'b one\nb two', 200);
739
+ expect(a.comparability).toBe('a-unreadable');
740
+ expect(a.supersetClaim).toBe('not-assessed');
741
+ expect(a.neitherIsSuperset).toBeNull();
742
+ expect(a.note).not.toMatch(/superset/i);
743
+ expect(a.note).toMatch(/nothing was compared/i);
744
+ expect(a.note).toMatch(/could not decode/i);
745
+ expect(a.note).toMatch(/^Draft A yielded no body text/);
746
+
747
+ const b = diffBodyLines('a one\na two', ' \n\n', 200);
748
+ expect(b.comparability).toBe('b-unreadable');
749
+ expect(b.supersetClaim).toBe('not-assessed');
750
+ expect(b.note).not.toMatch(/superset/i);
751
+
752
+ const both = diffBodyLines('', '', 200);
753
+ expect(both.comparability).toBe('both-unreadable');
754
+ expect(both.supersetClaim).toBe('not-assessed');
755
+ expect(both.note).not.toMatch(/identical/i);
756
+ expect(both.note).toMatch(/^NEITHER draft yielded any body text/);
757
+ expect(diffBodyLines('a one', '', 200).note).toMatch(/^Draft B yielded no body text/);
758
+ });
759
+
760
+ it('names the superset direction only when both sides were readable', () => {
761
+ expect(diffBodyLines('a\nb\nc', 'a\nb', 200).supersetClaim).toBe('a-superset-of-b');
762
+ expect(diffBodyLines('a\nb', 'a\nb\nc', 200).supersetClaim).toBe('b-superset-of-a');
763
+ expect(diffBodyLines('a\nb', 'a\nb', 200).supersetClaim).toBe('identical');
764
+ expect(diffBodyLines('a\nx', 'a\ny', 200).supersetClaim).toBe('neither');
765
+ expect(diffBodyLines('a\nb', 'a\nb', 200).comparability).toBe('compared');
766
+ });
767
+
768
+ it('truncates long one-sided diffs and flags it', () => {
769
+ const d = diffBodyLines('a1\na2\na3', 'b1\nb2\nb3', 2);
770
+ expect(d.onlyInA).toEqual(['a1', 'a2']);
771
+ expect(d.onlyInB).toEqual(['b1', 'b2']);
772
+ expect(d.truncated).toBe(true);
773
+ expect(d.note).toContain('truncated');
774
+ });
775
+ });
776
+
777
+ // ---------------------------------------------------------------------------
778
+ // REQUIREMENT 5 — THE CONTENT-LOSS CHECK.
779
+ //
780
+ // gog requires a body on EVERY `drafts update`, so re-threading and rewriting
781
+ // the body are the same operation. Adopting a mail client's replacement back
782
+ // onto the thread therefore overwrites whatever text lived only in the other
783
+ // copy. This is the guard, and it is deliberately mechanical: it compares two
784
+ // bodies and says which lines one holds that the other does not. It makes NO
785
+ // claim that either draft replaced the other.
786
+ // ---------------------------------------------------------------------------
787
+ describe('evaluateContentLoss', () => {
788
+ it('passes when the body being written contains every line the sibling holds', () => {
789
+ const c = evaluateContentLoss('s:1', 'kept one\nkept two', 'kept one\nkept two\nand a new sentence', 200);
790
+ expect(c.status).toBe('clean');
791
+ expect(c.linesOnlyInSibling).toEqual([]);
792
+ expect(c.linesOnlyInSiblingCount).toBe(0);
793
+ expect(c.siblingBodyLineCount).toBe(2);
794
+ expect(c.newBodyLineCount).toBe(3);
795
+ expect(c.note).not.toMatch(/WARNING/);
796
+ });
797
+
798
+ it('names the exact lines that would exist only in the sibling afterwards', () => {
799
+ const c = evaluateContentLoss(
800
+ 's:14092347734530621658',
801
+ 'Thanks for the note.\nTHE PARAGRAPH ONLY APPLE HAS.\nBest, Chris',
802
+ 'Thanks for the note.\nBest, Chris',
803
+ 200,
804
+ );
805
+ expect(c.status).toBe('would-lose');
806
+ expect(c.linesOnlyInSibling).toEqual(['THE PARAGRAPH ONLY APPLE HAS.']);
807
+ expect(c.linesOnlyInSiblingCount).toBe(1);
808
+ expect(c.note).toMatch(/WARNING/);
809
+ expect(c.note).toContain('s:14092347734530621658');
810
+ });
811
+
812
+ it('collapses runs of whitespace and blank lines, so re-indentation is not reported as loss', () => {
813
+ expect(evaluateContentLoss('s:1', ' kept one \n\n\nkept two', 'kept one\nkept two', 200).status).toBe('clean');
814
+ });
815
+
816
+ // The comparison is LINE-based, so a paragraph re-wrapped at a different
817
+ // width does read as loss. That is the honest, precision-biased direction —
818
+ // but the note has to say so rather than let the caller assume words vanished.
819
+ it('reports a genuinely re-wrapped paragraph as loss, and says the comparison is line-based', () => {
820
+ const c = evaluateContentLoss(
821
+ 's:1',
822
+ 'The handoff is at six on the 14th, as usual.',
823
+ 'The handoff is at six\non the 14th, as usual.',
824
+ 200,
825
+ );
826
+ expect(c.status).toBe('would-lose');
827
+ expect(c.linesOnlyInSibling).toEqual(['The handoff is at six on the 14th, as usual.']);
828
+ expect(c.note).toMatch(/re-?wrapp/i);
829
+ expect(c.note).toMatch(/line-based/i);
830
+ });
831
+
832
+ it('reports UNCHECKED rather than clean when the sibling has no readable body', () => {
833
+ const c = evaluateContentLoss('s:1', ' \n\n', 'anything', 200);
834
+ expect(c.status).toBe('unchecked');
835
+ expect(c.siblingBodyLineCount).toBe(0);
836
+ expect(c.note).toMatch(/nothing was compared/i);
837
+ });
838
+
839
+ it('caps the printed line list and flags the truncation, keeping the true count', () => {
840
+ const c = evaluateContentLoss('s:1', 'x1\nx2\nx3\nx4', 'kept', 2);
841
+ expect(c.linesOnlyInSibling).toEqual(['x1', 'x2']);
842
+ expect(c.linesOnlyInSiblingCount).toBe(4);
843
+ expect(c.truncated).toBe(true);
844
+ expect(c.note).toContain('truncated');
845
+ });
846
+
847
+ it('reports the similarity it measured, so the caller can judge the comparison', () => {
848
+ expect(evaluateContentLoss('s:1', 'a\nb', 'a\nb', 200).similarity).toBe(1);
849
+ expect(evaluateContentLoss('s:1', 'a\nb', 'c\nd', 200).similarity).toBe(0);
850
+ });
851
+
852
+ // HAZARD A. Two unrelated drafts share no text, so the check reports total
853
+ // divergence — and that is exactly the shape a genuine fork also has. It must
854
+ // therefore never let the result read as "this is the fork of that".
855
+ it('makes NO pairing claim, even when the two bodies overlap completely', () => {
856
+ for (const c of [
857
+ evaluateContentLoss('s:1', 'a\nb', 'a\nb', 200),
858
+ evaluateContentLoss('s:1', 'dentist appointment friday', 'unrelated invoice text', 200),
859
+ ]) {
860
+ expect(c.forkClaim).toBeNull();
861
+ expect(c.forkClaimNote).toMatch(/does not|no claim/i);
862
+ expect(c.forkClaimNote).toContain('gog_gmail_drafts_diff');
863
+ expect(JSON.stringify(c)).not.toMatch(/confirmed|replaced this draft/);
864
+ }
865
+ });
866
+ });
867
+
868
+ describe('unreadableSiblingCheck', () => {
869
+ it('is UNCHECKED, carries the reason, and still makes no pairing claim', () => {
870
+ const c = unreadableSiblingCheck('s:1', 'Google API error (404 notFound)');
871
+ expect(c.status).toBe('unchecked');
872
+ expect(c.note).toContain('Google API error (404 notFound)');
873
+ expect(c.linesOnlyInSibling).toEqual([]);
874
+ expect(c.siblingBodyLineCount).toBe(0);
875
+ expect(c.forkClaim).toBeNull();
876
+ });
877
+ });
878
+
879
+ // ---------------------------------------------------------------------------
880
+ // HAZARD A — GREETING, SIGN-OFF AND SIGNATURE ARE APPARATUS, NOT AUTHORSHIP.
881
+ //
882
+ // The property that disqualified quoted text — a mail client reproduces it
883
+ // identically on every message regardless of what the message says — is just as
884
+ // true of the salutation, the closing formula, the name under it and the
885
+ // client's own signature block. `Sent from my iPhone` is Apple Mail's DEFAULT
886
+ // signature, and `Hi Jennifer,` + `Thanks,` + `Chris` + `Sent from my iPhone` is
887
+ // 4 lines and 43 characters: on its own that cleared all three of the lineage
888
+ // minimums, so two genuinely unrelated one-sentence notes from one Apple Mail
889
+ // account paired as `confirmed`. Short confirmation + signature is the dominant
890
+ // shape of the co-parenting mailbox this feature serves, so that was the
891
+ // DEFAULT case, not a corner.
892
+ // ---------------------------------------------------------------------------
893
+ describe('authoredBodyLines — client boilerplate is apparatus', () => {
894
+ const apple = (sentence: string) => `Hi Jennifer,\n\n${sentence}\n\nThanks,\nChris\n\nSent from my iPhone`;
895
+
896
+ it('keeps only the sentence the author actually wrote', () => {
897
+ expect(authoredBodyLines(apple('Tuesday pickup at 5 works for me.')))
898
+ .toEqual(['Tuesday pickup at 5 works for me.']);
899
+ });
900
+
901
+ it('strips a salutation only on the FIRST line, never mid-body', () => {
902
+ expect(authoredBodyLines('Hi Jennifer,\nHey I forgot to say the bag is packed'))
903
+ .toEqual(['Hey I forgot to say the bag is packed']);
904
+ });
905
+
906
+ it('strips a closing formula wherever it sits, so one draft is not stripped and the other left', () => {
907
+ // Position-dependent stripping would remove `Best, Chris` from the copy
908
+ // that ends with it and keep it in the copy that has a sentence after it,
909
+ // manufacturing divergence between two copies of the same message.
910
+ expect(authoredBodyLines('Pickup at six.\nBest, Chris')).toEqual(['Pickup at six.']);
911
+ expect(authoredBodyLines('Pickup at six.\nBest, Chris\nONE MORE SENTENCE.'))
912
+ .toEqual(['Pickup at six.', 'ONE MORE SENTENCE.']);
913
+ });
914
+
915
+ it('strips an RFC 3676 `-- ` block and everything under it', () => {
916
+ expect(authoredBodyLines('Real line.\n-- \nChris Hall\n704-555-0100\nchris@example.com'))
917
+ .toEqual(['Real line.']);
918
+ });
919
+
920
+ it('strips the client signatures other clients write', () => {
921
+ expect(authoredBodyLines('Real line.\nSent from my Galaxy S24')).toEqual(['Real line.']);
922
+ expect(authoredBodyLines('Real line.\nGet Outlook for iOS')).toEqual(['Real line.']);
923
+ });
924
+
925
+ it('does NOT strip a sentence that merely begins with a closing word', () => {
926
+ expect(authoredBodyLines('Thanks for the note.\nBest of luck with the move'))
927
+ .toEqual(['Thanks for the note.', 'Best of luck with the move']);
928
+ // `Thanks,` + a sentence is not a sign-off: the tail is not a name.
929
+ expect(authoredBodyLines('Thanks, I will send the orthodontist invoice tomorrow'))
930
+ .toEqual(['Thanks, I will send the orthodontist invoice tomorrow']);
931
+ });
932
+
933
+ it('stops stripping under a sign-off at the first line that is not name-shaped', () => {
934
+ expect(authoredBodyLines('Thanks,\nChris\nPS the invoice is attached and paid'))
935
+ .toEqual(['PS the invoice is attached and paid']);
936
+ });
937
+ });
938
+
939
+ describe('measureBodyAgreement — boilerplate cannot establish lineage', () => {
940
+ const apple = (sentence: string) => `Hi Jennifer,\n\n${sentence}\n\nThanks,\nChris\n\nSent from my iPhone`;
941
+
942
+ // The reproduction from the review, verbatim: different subjects, different
943
+ // threadIds, no shared reply root — two unrelated drafts that agreed on
944
+ // nothing but the apparatus, and came back `meetsThreshold: true`.
945
+ it('finds NO agreement between two unrelated notes that share only the apparatus', () => {
946
+ const m = measureBodyAgreement(
947
+ apple('Tuesday pickup at 5 works for me.'),
948
+ apple('I paid the orthodontist invoice today.'),
949
+ );
950
+ expect(m.sharedAuthoredLines).toBe(0);
951
+ expect(m.sharedAuthoredChars).toBe(0);
952
+ expect(m.similarity).toBe(0);
953
+ expect(m.meetsThreshold).toBe(false);
954
+ expect(m.boilerplateLinesIgnored).toEqual({ original: 4, candidate: 4 });
955
+ });
956
+
957
+ it('still finds agreement when the two drafts share the SUBSTANCE', () => {
958
+ const m = measureBodyAgreement(
959
+ apple('Tuesday pickup at 5 works for me. I will be in the church lot by ten to.'),
960
+ `${apple('Tuesday pickup at 5 works for me. I will be in the church lot by ten to.')}\nAnd the swim bag is packed.`,
961
+ );
962
+ expect(m.sharedAuthoredLines).toBe(1);
963
+ expect(m.sharedAuthoredChars).toBeGreaterThan(FORK_MIN_SHARED_AUTHORED_CHARS);
964
+ });
965
+
966
+ it('counts quoting and boilerplate separately, so the caller can redo the arithmetic', () => {
967
+ const m = measureBodyAgreement(
968
+ 'Hi Jennifer,\nPickup at six.\nThanks,\nChris\n> quoted one\n> quoted two',
969
+ 'Pickup at six.',
970
+ );
971
+ expect(m.quotedLinesIgnored).toEqual({ original: 2, candidate: 0 });
972
+ expect(m.boilerplateLinesIgnored).toEqual({ original: 3, candidate: 0 });
973
+ expect(m.basisNote).toMatch(/sign-off|signature/i);
974
+ });
975
+ });
976
+
977
+ describe('evaluateForkPairing — the boilerplate false positive', () => {
978
+ const apple = (sentence: string) => `Hi Jennifer,\n\n${sentence}\n\nThanks,\nChris\n\nSent from my iPhone`;
979
+
980
+ it('never confirms two unrelated Apple drafts that share only greeting and signature', () => {
981
+ const original: DraftFacts = {
982
+ draftId: 'rOLD', from: 'chris@x.com', subject: 'Tuesday pickup', internalDate: '1000',
983
+ messageIdHeader: '<old@mail.gmail.com>', bodyText: apple('Tuesday pickup at 5 works for me.'),
984
+ };
985
+ const candidate: DraftFacts = {
986
+ draftId: 's:NEW', from: 'chris@x.com', subject: 'Orthodontist invoice', internalDate: '2000',
987
+ messageIdHeader: '<new@apple.com>', bodyText: apple('I paid the orthodontist invoice today.'),
988
+ appleSignals: ['X-Universally-Unique-Identifier: 8F3C'],
989
+ };
990
+ const p = evaluateForkPairing(original, candidate, 2);
991
+ expect(p.verdict).toBe('none');
992
+ expect(p.note).not.toContain('replaced draft');
993
+ expect(p.missing.join(' ')).toContain('no lineage signal');
994
+ });
995
+ });