switchroom 0.18.25 → 0.18.26

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.
@@ -18,15 +18,61 @@
18
18
  */
19
19
 
20
20
  import { GrammyError } from 'grammy'
21
+ import { guardDollarMath } from './render/dollar-math-guard.js'
22
+ import { guardAccidentalEmphasis } from './render/emphasis-guard.js'
23
+ import { guardAccidentalBlockConstructs } from './render/line-start-guard.js'
24
+ import { guardAccidentalInlinePairs } from './render/inline-pairs-guard.js'
21
25
 
22
26
  /** The `InputRichMessage` shape grammy 1.44 accepts on send AND edit. */
23
27
  export interface InputRichMessageMarkdown {
24
28
  markdown: string
25
29
  }
26
30
 
27
- /** Wrap raw GFM markdown into the rich-message input object. */
31
+ /**
32
+ * Neutralise ALL accidental Telegram markdown typesetting in one composed pass
33
+ * (#3252). Each sub-guard targets a DISJOINT set of trigger characters, skips
34
+ * code spans / fenced blocks verbatim (shared `splitCodeSegments`), is a strict
35
+ * no-op absent a real signal, and is idempotent — so the composition is itself
36
+ * idempotent and safe to apply once per send.
37
+ *
38
+ * ── Ordering (deliberate, not arbitrary) ──────────────────────────────────
39
+ * `guardDollarMath` runs LAST. It backslash-escapes `$` → `\$`, and the
40
+ * inline-pairs guard's approximation-tilde signal is `~(?=\$?\.?\d)` — a `~`
41
+ * glued to a `$digit`. If the dollar guard ran first, a body like
42
+ * `~$5M and ~$10M` would become `~\$5M …`, and the interposed `\` would hide
43
+ * the digit-adjacent tildes from `guardAccidentalInlinePairs`, leaving the
44
+ * accidental `~…~` strikethrough pair un-neutralised (a false negative /
45
+ * residual bug). Running inline-pairs FIRST escapes the tildes (`\~$5M`), then
46
+ * the dollar guard escapes the `$` — both spans are killed. Every other pair of
47
+ * guards is disjoint in the characters it inspects AND the characters it
48
+ * inserts (`\_ \* \> \. \~ \=\= \|\|` vs `\$`), so no other insertion can
49
+ * create or destroy a signal for a sibling. Verified by composition tests.
50
+ */
51
+ export function guardAccidentalFormatting(markdown: string): string {
52
+ let out = markdown
53
+ out = guardAccidentalEmphasis(out)
54
+ out = guardAccidentalBlockConstructs(out)
55
+ out = guardAccidentalInlinePairs(out)
56
+ out = guardDollarMath(out)
57
+ return out
58
+ }
59
+
60
+ /**
61
+ * Wrap raw GFM markdown into the rich-message input object.
62
+ *
63
+ * This is the ONE adapter every `{ markdown }` wire send funnels through
64
+ * (`sendRichMessage` / `editMessageText({ markdown })`) — the reply-tool final
65
+ * answer, draft-stream previews, cards, approvals, banners. It is therefore the
66
+ * single deterministic seam for the #3252 accidental-formatting guards (F1):
67
+ * applying `guardAccidentalFormatting` here guards EVERY markdown-parsed
68
+ * outbound exactly once, without touching `plain`-mode degradations (which
69
+ * bypass this wrapper and go straight to `sendMessage`, where no markdown
70
+ * parsing happens). The composed guard is a strict no-op for any body without
71
+ * an accidental-formatting signal and is idempotent, so callers that already
72
+ * ran it (or the streaming path that renders then re-wraps) stay byte-identical.
73
+ */
28
74
  export function richMessage(markdown: string): InputRichMessageMarkdown {
29
- return { markdown }
75
+ return { markdown: guardAccidentalFormatting(markdown) }
30
76
  }
31
77
 
32
78
  /**
@@ -0,0 +1,162 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { guardDollarMath } from "../../render/dollar-math-guard.js";
3
+ import { richMessage } from "../../rich-send.js";
4
+ import { computeReplyChunks } from "../../gateway/outbound-send-path.js";
5
+ import { RICH_MESSAGE_MAX_CHARS } from "../../format.js";
6
+
7
+ // Any U+1D400–U+1D7FF codepoint = a mathematical-alphanumeric (math-italic /
8
+ // math-bold) glyph — what a math renderer produces from a `$…$` span.
9
+ const MATH_GLYPH = /[\u{1D400}-\u{1D7FF}]/u;
10
+
11
+ /** Strip zero-width chars + defusing backslashes so we can assert the amount
12
+ * the reader copies is byte-identical to the original ASCII currency token.
13
+ * The wire body now flows through the composed richMessage guard, so besides
14
+ * the dollar defuser (`\$`) it may also carry the inline-pairs defuser (`\~`)
15
+ * for approximation tildes (`~$0.5M`) — strip both so the round-trip holds. */
16
+ function copyText(s: string): string {
17
+ return s.replace(/[​⁠]/g, "").replace(/\\([$~])/g, "$1");
18
+ }
19
+
20
+ describe("guardDollarMath (#3252)", () => {
21
+ const OFFENDING =
22
+ "acquisition ceiling is ~$0.5-0.9M but nearly all in small dealers ... is ~$150-450k";
23
+
24
+ it("neutralises the two-dollar-amount currency string so no `$…$` span can form", () => {
25
+ const out = guardDollarMath(OFFENDING);
26
+ // Both currency dollars are backslash-escaped → no unescaped `$` remains to
27
+ // open/close a math span.
28
+ expect(out).not.toMatch(/(?<!\\)\$/);
29
+ expect(out).toContain("\\$0.5-0.9M");
30
+ expect(out).toContain("\\$150-450k");
31
+ });
32
+
33
+ it("carries no math-italic glyphs (switchroom never emits U+1D400-range)", () => {
34
+ expect(guardDollarMath(OFFENDING)).not.toMatch(MATH_GLYPH);
35
+ // …nor does the raw source; the guard is what keeps the WIRE bytes clean.
36
+ expect(OFFENDING).not.toMatch(MATH_GLYPH);
37
+ });
38
+
39
+ it("preserves the visible amounts: stripping the defuser yields the original", () => {
40
+ expect(copyText(guardDollarMath(OFFENDING))).toBe(OFFENDING);
41
+ });
42
+
43
+ it("is a strict no-op for a single `$digit` prose token", () => {
44
+ const s = "grab a $5 coffee on the way";
45
+ expect(guardDollarMath(s)).toBe(s);
46
+ });
47
+
48
+ it("is a strict no-op when there is no `$digit` at all", () => {
49
+ const s = "the cost is unknown but the $ sign appears twice: $ and $";
50
+ expect(guardDollarMath(s)).toBe(s);
51
+ });
52
+
53
+ it("never touches `$` inside a code span", () => {
54
+ const s = "shell math `$x = $y` is fine and `$z = $w` too";
55
+ // Two code spans, each with 2 `$digit`? No — non-digit. Use digits to prove
56
+ // code is skipped even when it WOULD otherwise trigger.
57
+ const withDigits = "compute `$1 + $2` and also `$3 + $4` inline";
58
+ expect(guardDollarMath(s)).toBe(s);
59
+ expect(guardDollarMath(withDigits)).toBe(withDigits);
60
+ });
61
+
62
+ it("escapes prose dollars but leaves an adjacent code span verbatim", () => {
63
+ const s = "prices $10 and $20 — the var is `$PRICE = $10`";
64
+ const out = guardDollarMath(s);
65
+ // Prose amounts escaped:
66
+ expect(out).toContain("\\$10 and \\$20");
67
+ // Code span verbatim (its `$10` NOT escaped):
68
+ expect(out).toContain("`$PRICE = $10`");
69
+ });
70
+
71
+ // ── F3: widened detection (trailing `$`, `$.50`, `$ ` with a bare gap) ────
72
+ it("escapes a mix of leading- and trailing-`$` amounts (`$50 or 50$`)", () => {
73
+ const out = guardDollarMath("It costs $50 or 50$ depending on the vendor");
74
+ // Both dollars gone from the unescaped set → no `$…$` pair can form.
75
+ expect(out).not.toMatch(/(?<!\\)\$/);
76
+ expect(out).toContain("\\$50 or 50\\$");
77
+ });
78
+
79
+ it("escapes when one dollar is bare (`$10 today (down from $ yesterday)`)", () => {
80
+ const out = guardDollarMath("$10 today (down from $ yesterday)");
81
+ expect(out).not.toMatch(/(?<!\\)\$/);
82
+ });
83
+
84
+ it("escapes a `$.50` amount whose `$` is not digit-adjacent by one char", () => {
85
+ const out = guardDollarMath("$.50 here and $5 there");
86
+ expect(out).not.toMatch(/(?<!\\)\$/);
87
+ expect(out).toContain("\\$.50 here and \\$5 there");
88
+ });
89
+
90
+ it("leaves two non-currency `$` (shell-var prose) untouched — no false positive", () => {
91
+ const s = "use $foo and $bar as the two variables";
92
+ expect(guardDollarMath(s)).toBe(s);
93
+ });
94
+
95
+ // ── F5: idempotent — running the guard twice never doubles the backslash ──
96
+ it("is idempotent: guarding already-guarded text is a strict no-op", () => {
97
+ const once = guardDollarMath(OFFENDING);
98
+ expect(guardDollarMath(once)).toBe(once);
99
+ expect(once).not.toContain("\\\\$"); // never a doubled `\\$`
100
+ });
101
+ });
102
+
103
+ // F1/F2 regression: exercise the ACTUAL reply pipe. The `reply` tool's final
104
+ // answer flows executeReply → computeReplyChunks → sendReplyChunks →
105
+ // richMessage(chunk). `richMessage` is the single wire seam the guard now lives
106
+ // in; these assertions FAIL against the pre-fix code (bare `{ markdown }` wrap,
107
+ // no guard) and pass after. We drive the real `computeReplyChunks` output
108
+ // through the real `richMessage`, not `guardDollarMath` in isolation.
109
+ describe("reply-path integration (#3252 F1/F2)", () => {
110
+ const OFFENDING =
111
+ "acquisition ceiling is ~$0.5-0.9M but nearly all in small dealers ... is ~$150-450k";
112
+
113
+ it("richMessage() escapes the `$…$` pair on the markdown wire body", () => {
114
+ const wire = richMessage(OFFENDING);
115
+ // The bytes handed to sendRichMessage/editMessageText carry no unescaped `$`.
116
+ expect(wire.markdown).not.toMatch(/(?<!\\)\$/);
117
+ expect(wire.markdown).not.toMatch(MATH_GLYPH);
118
+ // Reader-visible amounts intact once the defuser is stripped.
119
+ expect(copyText(wire.markdown)).toBe(OFFENDING);
120
+ });
121
+
122
+ it("the full computeReplyChunks → richMessage reply pipe emits no unescaped `$`", () => {
123
+ // Exactly what executeReply feeds sendReplyChunks for a non-literal reply.
124
+ const chunks = computeReplyChunks({
125
+ effectiveText: OFFENDING,
126
+ literalText: false,
127
+ limit: RICH_MESSAGE_MAX_CHARS,
128
+ chunkMode: "newline",
129
+ });
130
+ expect(chunks.length).toBeGreaterThan(0);
131
+ for (const chunk of chunks) {
132
+ const wire = richMessage(chunk); // sendReplyChunks wraps each chunk this way
133
+ expect(wire.markdown).not.toMatch(/(?<!\\)\$/);
134
+ expect(wire.markdown).not.toMatch(MATH_GLYPH);
135
+ }
136
+ });
137
+
138
+ it("a single-amount reply is left with its dollar untouched on the wire", () => {
139
+ const wire = richMessage("the retainer is $2500 per month");
140
+ expect(wire.markdown).toContain("$2500");
141
+ expect(wire.markdown).not.toContain("\\$2500");
142
+ });
143
+ });
144
+
145
+ describe("guardDollarMath — link / table awareness (findings 1 & 3)", () => {
146
+ it("does NOT escape a `$` inside a markdown link destination", () => {
147
+ // Two dollars + a digit-adjacent one would normally arm the guard, but both
148
+ // live in URL query strings → structural → left verbatim.
149
+ const s = "buy [x](https://x.io?price=$5) or [y](https://y.io?price=$9)";
150
+ expect(guardDollarMath(s)).toBe(s);
151
+ });
152
+
153
+ it("does NOT escape `$` inside a real table's cells", () => {
154
+ const s = ["| Item | Cost |", "| --- | --- |", "| a | $5 |", "| b | $9 |"].join("\n");
155
+ expect(guardDollarMath(s)).toBe(s);
156
+ });
157
+
158
+ it("STILL escapes two currency dollars in ordinary prose", () => {
159
+ const out = guardDollarMath("spend was $5 today and $9 tomorrow");
160
+ expect(out).not.toMatch(/(?<!\\)\$/);
161
+ });
162
+ });
@@ -0,0 +1,205 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { guardAccidentalEmphasis } from "../../render/emphasis-guard.js";
3
+
4
+ // Any U+1D400–U+1D7FF codepoint would be a mathematical-alphanumeric glyph; the
5
+ // source never emits them — kept as a canary that the guard adds none.
6
+ const MATH_GLYPH = /[\u{1D400}-\u{1D7FF}]/u;
7
+
8
+ /** Strip defusing backslashes so we can assert the reader-visible/copy text is
9
+ * byte-identical to the original prose. */
10
+ function copyText(s: string): string {
11
+ return s.replace(/\\([_*])/g, "$1");
12
+ }
13
+
14
+ describe("guardAccidentalEmphasis (#3252) — accidental emphasis IS neutralised", () => {
15
+ it("escapes intra-word underscores in a snake_case identifier", () => {
16
+ const out = guardAccidentalEmphasis("the var is file_name_here in scope");
17
+ // No unescaped intra-word `_` remains to pair into an italic span.
18
+ expect(out).toContain("file\\_name\\_here");
19
+ expect(copyText(out)).toBe("the var is file_name_here in scope");
20
+ });
21
+
22
+ it("escapes intra-word asterisks in inline multiplication `a*b*c`", () => {
23
+ const out = guardAccidentalEmphasis("compute a*b*c for the product");
24
+ expect(out).toContain("a\\*b\\*c");
25
+ expect(copyText(out)).toBe("compute a*b*c for the product");
26
+ });
27
+
28
+ it("escapes digit-flanked `2*3*4` multiplication (2+ asterisks can pair)", () => {
29
+ const out = guardAccidentalEmphasis("the area is 2*3*4 units");
30
+ expect(out).toContain("2\\*3\\*4");
31
+ });
32
+
33
+ it("escapes a mixed digit/letter intra-word underscore run `v2_final_rc`", () => {
34
+ const out = guardAccidentalEmphasis("ship v2_final_rc today");
35
+ expect(out).toContain("v2\\_final\\_rc");
36
+ });
37
+
38
+ it("adds no math-italic glyphs", () => {
39
+ expect(guardAccidentalEmphasis("file_name_here and a*b")).not.toMatch(MATH_GLYPH);
40
+ });
41
+ });
42
+
43
+ describe("guardAccidentalEmphasis (#3252) — intended emphasis is LEFT UNTOUCHED (the critical no-false-positive cases)", () => {
44
+ it("leaves `**bold**` verbatim", () => {
45
+ const s = "this is **bold** text";
46
+ expect(guardAccidentalEmphasis(s)).toBe(s);
47
+ });
48
+
49
+ it("leaves `*italic*` verbatim", () => {
50
+ const s = "this is *italic* text";
51
+ expect(guardAccidentalEmphasis(s)).toBe(s);
52
+ });
53
+
54
+ it("leaves `_italic_` verbatim", () => {
55
+ const s = "this is _italic_ text";
56
+ expect(guardAccidentalEmphasis(s)).toBe(s);
57
+ });
58
+
59
+ it("leaves a multi-word `*italic phrase*` verbatim (boundary-flanked delimiters)", () => {
60
+ const s = "please read *the whole thing* carefully";
61
+ expect(guardAccidentalEmphasis(s)).toBe(s);
62
+ });
63
+
64
+ it("leaves `**bold**` and `_italic_` mixed in one line verbatim", () => {
65
+ const s = "**Header** then _emphasis_ and more **bold**";
66
+ expect(guardAccidentalEmphasis(s)).toBe(s);
67
+ });
68
+
69
+ it("leaves an intended italic that WRAPS a glob (`*.ts is a glob*`) verbatim", () => {
70
+ // The opening `*` is boundary-flanked (space before, `.` after) → not
71
+ // intra-word → deliberately left alone so the intended italic survives.
72
+ const s = "note: *.ts is a glob* pattern";
73
+ expect(guardAccidentalEmphasis(s)).toBe(s);
74
+ });
75
+
76
+ it("leaves a leading-underscore identifier `_private` verbatim (indistinguishable from italic)", () => {
77
+ const s = "the _private field and _internal state";
78
+ expect(guardAccidentalEmphasis(s)).toBe(s);
79
+ });
80
+
81
+ it("leaves space-flanked operators (`3 * 4`) verbatim — GFM cannot emphasise them", () => {
82
+ const s = "the product 3 * 4 equals 12";
83
+ expect(guardAccidentalEmphasis(s)).toBe(s);
84
+ });
85
+
86
+ it("leaves a bare trailing glob (`rm *`) verbatim", () => {
87
+ const s = "run rm * to clear the dir";
88
+ expect(guardAccidentalEmphasis(s)).toBe(s);
89
+ });
90
+
91
+ it("leaves a lone glob (`*.ts`) verbatim", () => {
92
+ const s = "match *.ts files only";
93
+ expect(guardAccidentalEmphasis(s)).toBe(s);
94
+ });
95
+ });
96
+
97
+ describe("guardAccidentalEmphasis (#3252) — pair threshold (finding 4)", () => {
98
+ // A single delimiter in the whole message can NEVER form an emphasis pair, so
99
+ // a lone intra-word `_`/`*` is provably inert and is left byte-identical.
100
+ it("leaves a LONE intra-word underscore `file_name` verbatim (can't pair)", () => {
101
+ const s = "the var is file_name in scope";
102
+ expect(guardAccidentalEmphasis(s)).toBe(s);
103
+ });
104
+
105
+ it("leaves a LONE intra-word asterisk `2*3` verbatim (can't pair)", () => {
106
+ const s = "the area is 2*3 units";
107
+ expect(guardAccidentalEmphasis(s)).toBe(s);
108
+ });
109
+
110
+ it("STILL escapes a lone intra-word `_` when a later `_italic_` gives it a pair", () => {
111
+ // Three underscores total (one intra-word + an intended `_italic_`); Telegram
112
+ // could mis-pair them, so the intra-word `_` MUST be escaped even though it is
113
+ // the only intra-word one. Threshold 2 (total count) catches this correctly.
114
+ const out = guardAccidentalEmphasis("the file_name and _italic_ here");
115
+ expect(out).toContain("file\\_name");
116
+ // The intended italic delimiters (boundary-flanked) are left untouched.
117
+ expect(out).toContain("_italic_");
118
+ });
119
+ });
120
+
121
+ describe("guardAccidentalEmphasis (#3252) — link / autolink awareness (finding 1)", () => {
122
+ it("does NOT escape intra-word `_` inside a markdown link destination", () => {
123
+ const s = "see [docs](https://x.io/a_b_c_d) for more";
124
+ // URL underscores are structural → passed through UNMODIFIED.
125
+ expect(guardAccidentalEmphasis(s)).toBe(s);
126
+ });
127
+
128
+ it("does NOT escape `_` inside a bare autolinked URL", () => {
129
+ const s = "read https://x.io/foo_bar_baz now";
130
+ expect(guardAccidentalEmphasis(s)).toBe(s);
131
+ });
132
+
133
+ it("STILL guards the link LABEL prose while protecting the URL", () => {
134
+ const out = guardAccidentalEmphasis("[file_name_here](https://x.io/a_b_c)");
135
+ // Label (rendered prose) is escaped; the `(url)` destination is verbatim.
136
+ expect(out).toContain("[file\\_name\\_here]");
137
+ expect(out).toContain("(https://x.io/a_b_c)");
138
+ });
139
+
140
+ it("protects a link destination WITH a title", () => {
141
+ const s = 'see [docs](https://x.io/a_b_c "the_title") now';
142
+ expect(guardAccidentalEmphasis(s)).toBe(s);
143
+ });
144
+ });
145
+
146
+ describe("guardAccidentalEmphasis (#3252) — trailing-alnum emphasis closer (finding 5)", () => {
147
+ // `*cat*s` / `_cat_s`: the closing delimiter is intra-word (t*s / t_s). Under
148
+ // strict CommonMark it cannot close emphasis, but Telegram may be more liberal
149
+ // (UAT-gated). Documented current behaviour: the intra-word closer is escaped
150
+ // (2 asterisks/underscores total → armed). Copy-round-trips to the original.
151
+ it("escapes the intra-word closer of `*cat*s`", () => {
152
+ const out = guardAccidentalEmphasis("a *cat*s tail");
153
+ expect(out).toContain("*cat\\*s");
154
+ expect(copyText(out)).toBe("a *cat*s tail");
155
+ });
156
+
157
+ it("escapes the intra-word closer of `_cat_s`", () => {
158
+ const out = guardAccidentalEmphasis("a _cat_s tail");
159
+ expect(out).toContain("_cat\\_s");
160
+ expect(copyText(out)).toBe("a _cat_s tail");
161
+ });
162
+ });
163
+
164
+ describe("guardAccidentalEmphasis (#3252) — code-span awareness", () => {
165
+ it("never touches intra-word `_`/`*` inside a code span", () => {
166
+ const s = "the identifier `file_name_here` and math `a*b*c` are code";
167
+ expect(guardAccidentalEmphasis(s)).toBe(s);
168
+ });
169
+
170
+ it("escapes prose but leaves an adjacent code span verbatim", () => {
171
+ // Two intra-word underscores in prose (`file_name_here`) → armed; the code
172
+ // span's `file_name` is verbatim and does NOT count toward the threshold.
173
+ const out = guardAccidentalEmphasis("prose file_name_here but code `file_name`");
174
+ expect(out).toContain("file\\_name\\_here but code `file_name`");
175
+ });
176
+
177
+ it("never touches intra-word delimiters inside a fenced code block", () => {
178
+ const s = "```\nfoo_bar = a*b*c\n```\ntail";
179
+ expect(guardAccidentalEmphasis(s)).toBe(s);
180
+ });
181
+ });
182
+
183
+ describe("guardAccidentalEmphasis (#3252) — no-op & idempotency", () => {
184
+ it("is a strict no-op when there is no `_` or `*` at all", () => {
185
+ const s = "a plain sentence with no delimiters at all";
186
+ expect(guardAccidentalEmphasis(s)).toBe(s);
187
+ });
188
+
189
+ it("is a strict no-op when the only `_`/`*` are intended emphasis", () => {
190
+ const s = "**bold** and _italic_ only";
191
+ expect(guardAccidentalEmphasis(s)).toBe(s);
192
+ });
193
+
194
+ it("is idempotent: guarding already-guarded text is a strict no-op", () => {
195
+ const once = guardAccidentalEmphasis("file_name_here and a*b");
196
+ expect(guardAccidentalEmphasis(once)).toBe(once);
197
+ expect(once).not.toContain("\\\\_"); // never a doubled backslash
198
+ expect(once).not.toContain("\\\\*");
199
+ });
200
+
201
+ it("does not double-escape a pre-escaped `\\_` intra-word delimiter", () => {
202
+ const s = "file\\_name here";
203
+ expect(guardAccidentalEmphasis(s)).toBe(s);
204
+ });
205
+ });
@@ -0,0 +1,138 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { guardAccidentalFormatting, richMessage } from "../../rich-send.js";
3
+
4
+ // Any U+1D400–U+1D7FF codepoint = a mathematical-alphanumeric glyph (what a
5
+ // `$…$` math span typesets to on the reader's screen).
6
+ const MATH_GLYPH = /[\u{1D400}-\u{1D7FF}]/u;
7
+
8
+ /** Strip the defusing backslashes so we can assert the reader-visible / copied
9
+ * text is byte-identical to the original ASCII. */
10
+ function copyText(s: string): string {
11
+ return s.replace(/\\([$_*>.~=|])/g, "$1");
12
+ }
13
+
14
+ // The composed seam guard (#3252): guardAccidentalEmphasis →
15
+ // guardAccidentalBlockConstructs → guardAccidentalInlinePairs → guardDollarMath.
16
+ // These tests prove the guards COMPOSE without one guard's inserted backslash
17
+ // creating or destroying a signal for another — the interference question.
18
+ describe("guardAccidentalFormatting composition (#3252)", () => {
19
+ it("neutralises a message that trips emphasis + dollar + tilde at once", () => {
20
+ // `file_name_here` (2 intra-word `_` → can pair), `~$5M … ~$10M`
21
+ // (digit-adjacent tildes AND a two-dollar currency span) — three guards fire.
22
+ const src = "the file_name_here budget trims ~$5M, down from ~$10M last year";
23
+ const out = guardAccidentalFormatting(src);
24
+ // Emphasis: the intra-word underscores are escaped (no `_…_` pair).
25
+ expect(out).toContain("file\\_name\\_here");
26
+ // Dollar: no unescaped `$` remains to open a math span.
27
+ expect(out).not.toMatch(/(?<!\\)\$/);
28
+ // Tilde: BOTH approximation tildes escaped so no `~…~` strikethrough forms.
29
+ // This is the interference case — dollar runs last so its `\$` never hides
30
+ // the digit-adjacent tildes from the inline-pairs guard.
31
+ expect(out).not.toMatch(/(?<!\\)~/);
32
+ expect(out).toContain("\\~\\$5M");
33
+ expect(out).toContain("\\~\\$10M");
34
+ // No math glyphs, and stripping the defusers yields the original prose.
35
+ expect(out).not.toMatch(MATH_GLYPH);
36
+ expect(copyText(out)).toBe(src);
37
+ });
38
+
39
+ it("handles a line-start blockquote + operator-mark + dollar body", () => {
40
+ const src = ">2x growth means x==y and a==b while spend hit $40 and $80";
41
+ const out = guardAccidentalFormatting(src);
42
+ // Line-start `>2x` blockquote-promotion escaped.
43
+ expect(out).toContain("\\>2x");
44
+ // Both `==` operators neutralised (word-flanked → not intended ==mark==).
45
+ expect(out).toContain("x\\=\\=y");
46
+ expect(out).toContain("a\\=\\=b");
47
+ // Dollars escaped.
48
+ expect(out).not.toMatch(/(?<!\\)\$/);
49
+ expect(copyText(out)).toBe(src);
50
+ });
51
+
52
+ it("leaves a 4-digit-year accidental ordered-list alone except the dot", () => {
53
+ const src = "2026. was the year the file_name_here convention changed";
54
+ const out = guardAccidentalFormatting(src);
55
+ expect(out).toContain("2026\\. was");
56
+ expect(out).toContain("file\\_name\\_here");
57
+ });
58
+
59
+ it("never touches DELIBERATE formatting: bold, italic, strike, code, blockquote", () => {
60
+ const src =
61
+ "**bold** and *italic* and _under_ and ~~strike~~ and `a_b*c` and\n> real quote";
62
+ // No accidental signal anywhere → strict no-op.
63
+ expect(guardAccidentalFormatting(src)).toBe(src);
64
+ });
65
+
66
+ it("never touches content inside code spans / fenced blocks", () => {
67
+ const src =
68
+ "prose $10 and $20 but the snippet `x==y && file_name ~5` stays verbatim";
69
+ const out = guardAccidentalFormatting(src);
70
+ expect(out).toContain("`x==y && file_name ~5`");
71
+ // Prose dollars still escaped outside the code span.
72
+ expect(out).not.toMatch(/(?<!\\)\$(?=\d)/);
73
+ });
74
+
75
+ it("is idempotent: composing already-composed text is a strict no-op", () => {
76
+ const src = "file_name_here ~$5M and ~$10M with x==y and a==b, >2x, 2026. done";
77
+ const once = guardAccidentalFormatting(src);
78
+ expect(guardAccidentalFormatting(once)).toBe(once);
79
+ // No doubled backslashes anywhere.
80
+ expect(once).not.toMatch(/\\\\/);
81
+ });
82
+
83
+ it("passes a markdown link with a `_`/`~`/`==`-laden URL through UNMODIFIED (finding 1)", () => {
84
+ const src = "see [docs](https://x.io/a_b_c?p==1&q~2) and [more](https://y.io/foo_bar)";
85
+ // Every trigger char in the URL is structural → the whole body is verbatim.
86
+ expect(guardAccidentalFormatting(src)).toBe(src);
87
+ });
88
+
89
+ it("passes a bare autolinked URL through UNMODIFIED (finding 1)", () => {
90
+ const src = "read https://en.wikipedia.org/wiki/Foo_Bar_Baz today";
91
+ expect(guardAccidentalFormatting(src)).toBe(src);
92
+ });
93
+
94
+ it("guards the link LABEL but protects the destination (finding 1)", () => {
95
+ const out = guardAccidentalFormatting("[a_b_c](https://x.io/p_q_r) spent $5 and $9");
96
+ // Label prose escaped; URL verbatim; prose dollars still escaped.
97
+ expect(out).toContain("[a\\_b\\_c]");
98
+ expect(out).toContain("(https://x.io/p_q_r)");
99
+ expect(out).not.toMatch(/(?<!\\)\$/);
100
+ });
101
+
102
+ it("passes a GFM table with empty cells through UNMODIFIED (finding 3)", () => {
103
+ // A real table (has a delimiter row) — its structural pipes/empty cells and
104
+ // any `_`/`$` inside cells must survive. `|a||b|` = a real empty cell.
105
+ const src = ["| A | B | C |", "| --- | --- | --- |", "|a||b||c|", "| x_y | $5 | ~3 |"].join("\n");
106
+ expect(guardAccidentalFormatting(src)).toBe(src);
107
+ });
108
+
109
+ it("STILL guards a genuine accidental `a||b` in prose (not a table)", () => {
110
+ // Two word-flanked `||` in PROSE (no delimiter row) → armed spoiler guard.
111
+ const out = guardAccidentalFormatting("logic is a||b and c||d here");
112
+ expect(out).toContain("a\\|\\|b");
113
+ expect(out).toContain("c\\|\\|d");
114
+ });
115
+
116
+ // FINDING 2 (documented non-issue): switchroom's IR renderer emits code
117
+ // blocks ONLY as backtick FENCES (render.ts renderCodeBlock), never as
118
+ // 4-space-indented CommonMark code. So renderer output reaching richMessage()
119
+ // never contains an indented code block. The emphasis/inline-pairs/dollar
120
+ // guards therefore do NOT special-case a 4-space indent (only line-start does,
121
+ // for its own blockquote/list heuristics). This test PINS that current
122
+ // behaviour: a 4-space-indented prose line is treated as ordinary prose and
123
+ // its currency `$` is guarded like any other. (If a future change ever routes
124
+ // raw indented code blocks to the wire, revisit — see guards-integration-note.)
125
+ it("treats a 4-space-indented line as prose (finding 2 — renderer emits fences, not indented code)", () => {
126
+ const out = guardAccidentalFormatting(" spend was $5 and $10 total");
127
+ expect(out).toContain("\\$5");
128
+ expect(out).toContain("\\$10");
129
+ });
130
+
131
+ it("richMessage() applies the composed guard on the wire body exactly once", () => {
132
+ const src = "file_name spend ~$5M vs ~$10M, x==y";
133
+ const wire = richMessage(src);
134
+ expect(wire.markdown).toBe(guardAccidentalFormatting(src));
135
+ // And re-wrapping (streaming re-render) is byte-stable.
136
+ expect(richMessage(wire.markdown).markdown).toBe(wire.markdown);
137
+ });
138
+ });