polytypo 1.0.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.
Files changed (63) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +109 -0
  4. data/lib/polytypo/data/README.md +20 -0
  5. data/lib/polytypo/data/UNICODE +1 -0
  6. data/lib/polytypo/data/VERSION +1 -0
  7. data/lib/polytypo/data/fixtures/de-CH.json +501 -0
  8. data/lib/polytypo/data/fixtures/de-DE.json +547 -0
  9. data/lib/polytypo/data/fixtures/el.json +239 -0
  10. data/lib/polytypo/data/fixtures/en-GB.json +1274 -0
  11. data/lib/polytypo/data/fixtures/en-US.json +1807 -0
  12. data/lib/polytypo/data/fixtures/fi.json +1306 -0
  13. data/lib/polytypo/data/fixtures/fr-CA.json +268 -0
  14. data/lib/polytypo/data/fixtures/fr.json +603 -0
  15. data/lib/polytypo/data/fixtures/locale-resolution.json +209 -0
  16. data/lib/polytypo/data/fixtures/ru.json +688 -0
  17. data/lib/polytypo/data/fixtures/sv.json +1290 -0
  18. data/lib/polytypo/data/locales/de-CH.json +77 -0
  19. data/lib/polytypo/data/locales/de-DE.json +76 -0
  20. data/lib/polytypo/data/locales/el.json +90 -0
  21. data/lib/polytypo/data/locales/en-GB.json +115 -0
  22. data/lib/polytypo/data/locales/en-US.json +133 -0
  23. data/lib/polytypo/data/locales/fi.json +136 -0
  24. data/lib/polytypo/data/locales/fr-CA.json +78 -0
  25. data/lib/polytypo/data/locales/fr.json +84 -0
  26. data/lib/polytypo/data/locales/registry.json +9 -0
  27. data/lib/polytypo/data/locales/ru.json +112 -0
  28. data/lib/polytypo/data/locales/sv.json +124 -0
  29. data/lib/polytypo/data/rules/dashes.md +1238 -0
  30. data/lib/polytypo/data/rules/order.json +78 -0
  31. data/lib/polytypo/data/schema/fixtures.schema.json +79 -0
  32. data/lib/polytypo/data/schema/locale.schema.json +235 -0
  33. data/lib/polytypo/data/schema/registry.schema.json +29 -0
  34. data/lib/polytypo/data/schema/resolution.schema.json +50 -0
  35. data/lib/polytypo/engine/codepoints.rb +24 -0
  36. data/lib/polytypo/engine/edits.rb +64 -0
  37. data/lib/polytypo/engine/locale.rb +138 -0
  38. data/lib/polytypo/engine/pipeline.rb +61 -0
  39. data/lib/polytypo/engine/registry.rb +47 -0
  40. data/lib/polytypo/engine/rules/apostrophe.rb +127 -0
  41. data/lib/polytypo/engine/rules/dash_shared.rb +342 -0
  42. data/lib/polytypo/engine/rules/dashes.rb +125 -0
  43. data/lib/polytypo/engine/rules/ellipsis.rb +100 -0
  44. data/lib/polytypo/engine/rules/hyphen.rb +207 -0
  45. data/lib/polytypo/engine/rules/nbsp.rb +616 -0
  46. data/lib/polytypo/engine/rules/quote_ambiguity.rb +241 -0
  47. data/lib/polytypo/engine/rules/quotes.rb +420 -0
  48. data/lib/polytypo/engine/rules/ranges.rb +124 -0
  49. data/lib/polytypo/engine/rules/spaces.rb +232 -0
  50. data/lib/polytypo/engine/rules/symbols.rb +291 -0
  51. data/lib/polytypo/engine/rules.rb +19 -0
  52. data/lib/polytypo/engine/sentinels.rb +23 -0
  53. data/lib/polytypo/engine/unicode_util.rb +390 -0
  54. data/lib/polytypo/errors.rb +24 -0
  55. data/lib/polytypo/modes/html.rb +233 -0
  56. data/lib/polytypo/modes/markdown.rb +187 -0
  57. data/lib/polytypo/modes/parse_error.rb +19 -0
  58. data/lib/polytypo/modes/runner.rb +57 -0
  59. data/lib/polytypo/modes/spans.rb +132 -0
  60. data/lib/polytypo/version.rb +5 -0
  61. data/lib/polytypo.rb +91 -0
  62. data/polytypo.gemspec +37 -0
  63. metadata +122 -0
@@ -0,0 +1,1238 @@
1
+ # Rule: `dashes`
2
+
3
+ **Order:** 30. **Default:** on. **Modes:** text, html, markdown.
4
+ **Spec version:** 0.6.0 (0.2.0 for everything except the 0.5.0/0.6.0 changes noted inline and in
5
+ §8 History).
6
+
7
+ ---
8
+
9
+ ## 1. Purpose
10
+
11
+ `dashes` renders the **parenthetical dash** — the one that interrupts a sentence — like this —
12
+ in the form the locale prescribes. **As of spec 0.5.0, this rule no longer recognises the
13
+ numeric/date range dash** (`1914–1918`); that moved to [ranges.md](ranges.md) (order 25, off by
14
+ default).
15
+
16
+ **§1 through §7 below are current and normative for `dashes` alone.** §3.2's shared token-scanning
17
+ steps are consumed identically by `ranges` (`ranges.md` §3.1) and are described here because this
18
+ is their canonical home, not because they are historical. Only **§8, History**, is non-normative:
19
+ it is where the pre-0.5.0 combined rule's development — the guards it took to reach the current
20
+ algorithm, and the range-specific reasoning that used to live here and now lives in `ranges.md` —
21
+ is preserved for readers who want the reasoning trail. Nothing in §1-§7 requires the reader to
22
+ mentally subtract old range-branch statements from current parenthetical ones; where §1-§7
23
+ mentions `ranges` at all, it is describing a current, live interaction between the two rules, not
24
+ a retired one.
25
+
26
+ `dashes` does not touch the ordinary hyphen inside a compound word — which, in the few
27
+ morphological forms where the hyphen must additionally be protected from a line break, belongs
28
+ to `hyphen` at order 35 — and does not touch a digit-flanked stroke at all: that shape belongs to
29
+ `ranges` exclusively, and `dashes` declines it **unconditionally**, whether or not `ranges` is
30
+ enabled (operator decision, spec 0.5.0; see §3.2's note after step 7, and ranges.md §3.2's G1-G5).
31
+ `dashes` never reinterprets a digit-flanked hyphen as a parenthetical dash — that was already true
32
+ in every prior spec version, since the two branches were always mutually exclusive per token; the
33
+ 0.5.0 split makes that exclusivity a boundary between two rules instead of two branches of one.
34
+
35
+ The parenthetical form is governed by one enum in the locale file: a dash _length_ (`em` or `en`)
36
+ and a _spacing_ (`tight` or `spaced`), or the opt-out value `none`.
37
+
38
+ ---
39
+
40
+ ## 2. Locale data consumed
41
+
42
+ - `dash.parenthetical` — one of `"em-tight"`, `"em-spaced"`, `"en-tight"`, `"en-spaced"`,
43
+ `"none"`.
44
+
45
+ **As of spec 0.5.0, `dashes` no longer reads `dash.range`** — that field is now read exclusively
46
+ by `ranges` ([ranges.md](ranges.md) §2), under the same key, with the same values and the same
47
+ citations; the 0.5.0 split moved which rule reads the field, not the field itself or what
48
+ supports it (operator decision: no new locale claim was made by the split).
49
+
50
+ **Evidence for this list.** A locale attests **membership** — that these tokens belong in this
51
+ list — and this rule owns the **mechanism** it applies to them. A `sources` citation is not
52
+ required to name a code point or a binding the locale file has no way to vary. The principle is
53
+ stated once, normatively, in [nbsp.md](nbsp.md) §2.1 and governs every list-valued field in
54
+ `locale.schema.json`. **`"none"` means the locale has no verified convention for that use, and
55
+ the rule must emit nothing at all for a token of that kind** — not a fallback, not a "sensible
56
+ default", nothing. A missing citation is never a licence to guess (PLAN.md §6.1), and a locale
57
+ that has not been researched must round-trip its input untouched rather than acquire a
58
+ plausible-looking dash.
59
+
60
+ Nothing else. The choice between a breaking and a no-break space on the left of a spaced
61
+ dash is not made here: this rule always emits U+0020, and `nbsp` (order 70) may later
62
+ promote it.
63
+
64
+ ---
65
+
66
+ ## 3. Algorithm
67
+
68
+ Input is a code-point array `cp[0 … n-1]`.
69
+
70
+ ### 3.1 Character classes
71
+
72
+ | Class | Members |
73
+ | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
74
+ | `DASH` | U+002D (hyphen-minus), U+2010 (hyphen), U+2013 (en dash), U+2014 (em dash), U+2212 (minus sign) — see the note below the table |
75
+ | `DIGIT` | U+0030–U+0039 only. **ASCII digits only** — see [ranges.md](ranges.md) §7 (the limitation matters for `ranges`' digit-value comparisons; `dashes` never reads a digit's value) |
76
+ | `LETTER` | any code point whose Unicode general category is `Lu`, `Ll`, `Lt`, `Lm` or `Lo`, or `Mn`, `Mc`, `Me` (combining marks count as letter-continuation, so a decomposed `é` is treated as one letter followed by a mark) |
77
+ | `SPACE` | U+0020 only |
78
+ | `BREAK` | U+000A, U+000D, U+000B, U+000C, U+0085, U+2028, U+2029 |
79
+ | `NOBREAK-SPACE` | U+00A0, U+202F |
80
+ | `ROMAN` | the seven uppercase Roman-numeral letters only: U+0049 `I`, U+0056 `V`, U+0058 `X`, U+004C `L`, U+0043 `C`, U+0044 `D`, U+004D `M`. Lower-case forms are **not** members — see §3.4 P4 |
81
+ | `INERT-DASH` | U+00AD (soft hyphen), U+2011 (non-breaking hyphen), U+2012 (figure dash), U+2015 (horizontal bar), U+FE58, U+FE63, U+FF0D |
82
+ | `JOINER` | U+2060 (word joiner) only. As of spec 0.5.0, produced exclusively by `ranges` (ranges.md §3.3.1) — `dashes` itself never emits one, but still reads through an adjacent run of them (§3.2a, §3.2b), since a `ranges`-produced joiner can sit next to a `dashes` candidate token |
83
+
84
+ `INERT-DASH` members are **never** candidates and are **never** produced. Two of the seven are
85
+ protective markers owned by someone else — U+00AD is invisible formatting, and U+2011 is
86
+ `hyphen` (order 35)'s own output — and stay excluded on that basis alone. The rest —
87
+ U+2012, U+2015, U+FE58, U+FE63, U+FF0D — are each a *specialised* dash with its own reason to
88
+ exist (digit-width tabular alignment, the dialogue dash some traditions use on purpose, CJK
89
+ compatibility forms), not a substitute for a missing key, and reclassifying one would erase the
90
+ distinction the author reached for that code point to make.
91
+
92
+ **`DASH` beyond U+002D/U+2013/U+2014.** U+2010 (hyphen) and U+2212 (minus sign) are members too
93
+ (added in spec 0.2.0): both are plain typewriter or OCR substitutes for an ordinary hyphen-minus,
94
+ governed by exactly the same rationale as U+002D in step 2 below. U+2013 and U+2014 are ordinary
95
+ `DASH` members too, with **no token-level special case** (spec 0.2.0 retires the guard that used
96
+ to give them one — see step 2a's history note), **with one narrow exception added in spec 0.6.0
97
+ (§3.4 P5): a run consisting of exactly one U+2013 is declined unconditionally, in every locale**.
98
+ Every other mixture of `DASH` glyphs — including a run mixing U+2013 with U+002D/U+2010/U+2212/
99
+ U+2014, and every run containing U+2014 — is still promoted to the locale's form exactly as a
100
+ pure-hyphen run would be. This treats the dash's *length* the same way this rule has always
101
+ treated its spacing — as something to correct, not preserve — on the view that a dash's length in
102
+ ordinary prose is at least as often a copy-paste artefact or plain unfamiliarity with which mark
103
+ is which as it is a deliberate choice, and an author who wants their own dash typography left
104
+ alone has always had the option of not running the pipeline. **P5 is not a retraction of that
105
+ view for dashes in general — it is a narrower, evidence-driven claim about one specific glyph
106
+ used alone: see §3.4 P5 and §8.8.**
107
+
108
+ **Unicode version.** The general categories and case mappings this rule reads are those of the UCD version pinned in `spec/UNICODE` (`17.0`). The pin is normative for the **derived tables**, not for the host runtime — see [pipeline-idempotency.md](pipeline-idempotency.md) §6a, which also specifies the canary fixtures that make the pin detectable. If an author wrote
109
+ U+2011 they meant it.
110
+
111
+ ### 3.2 The dash token
112
+
113
+ 1. Scan left to right. At the first index `i` with `cp[i]` in `DASH`, find the maximal run of
114
+ `DASH` members: `s = i`, `e` = first index `> s` not in `DASH`, `k = e - s`.
115
+ 2. If `k > 3`, the run is decoration (a horizontal rule, a signature line, a Markdown
116
+ `---` setext underline of arbitrary length). Emit nothing; set `i = e`; continue.
117
+ 2a. **(Retired, spec 0.2.0. Number kept — see the note at the end of this step.)** Spec 0.1.0
118
+ had an authored-dash guard here: any run containing U+2013 or U+2014 was declined outright,
119
+ glyph and spacing both, whatever the locale said. It is gone. U+2013 and U+2014 carry no
120
+ token-level special case anywhere in this algorithm; a run holding them is classified and
121
+ replaced exactly as the same run spelled with U+002D would be. Length is corrected along
122
+ with spacing, by the same replacement tables in §3.3 and §3.4.
123
+
124
+ **Full development history — why the guard existed, why it was narrowed once, and why it was
125
+ then retired outright — is §8.1**, kept there because none of it is required to implement the
126
+ current algorithm; the two lines above are the complete current rule.
127
+
128
+ Numbered `2a` rather than renumbered to `3`: six documents cite the guards of this section by
129
+ number, and a step that is retired but still occupies its number costs less than a
130
+ renumbering would.
131
+
132
+ 3. Determine the **outer spacing** of the token:
133
+ - `lsp = 1` if `s > 0` and `cp[s-1]` is `SPACE`, else `0`;
134
+ - `rsp = 1` if `e < n` and `cp[e]` is `SPACE`, else `0`.
135
+ After `spaces` (order 10) any space run adjacent to the token has length exactly 1 unless
136
+ it borders a line terminator, so a single-code-point probe is sufficient.
137
+
138
+ **`SPACE` here means U+0020 and nothing else. A `NOBREAK-SPACE` is not this rule's
139
+ spacing.** If `cp[s-1]` or `cp[e]` is U+00A0 or U+202F, that side's `lsp`/`rsp` is `0`, the
140
+ no-break space becomes `cp[L]`/`cp[R]`, and **step 6 then declines the token** because
141
+ `cp[L]`/`cp[R]` is space-like. A dash touching a no-break space is therefore never edited,
142
+ on either side.
143
+
144
+ **This is a principle, not a spacing heuristic, and it replaces two earlier attempts that
145
+ were.** `dashes` normalises _ordinary sentence spacing_, which is U+0020. A no-break space
146
+ beside a dash was put there by somebody else — by the author, or by `nbsp` (order 70), whose
147
+ entire emission alphabet is U+00A0 and U+202F. It is not this rule's to reinterpret, and a
148
+ token that touches one is not this rule's to claim.
149
+
150
+ The consequence is the one that matters: **every code point `nbsp` can emit is inert for
151
+ this rule**, so `nbsp` cannot create a dash token, cannot change one's spacing verdict, and
152
+ cannot revive one this rule declined. That discharges the composition obligation against
153
+ `nbsp` structurally rather than case by case — see
154
+ [pipeline-idempotency.md](pipeline-idempotency.md) §5.1a, where it is generalised as
155
+ condition **CO-S**. The two earlier formulations ("a `NOBREAK-SPACE` counts as spacing",
156
+ then "counts on the left only") each fixed the witness in front of them and left the next
157
+ one reachable; §8.2 defect (d) records how.
158
+
159
+ 4. **Symmetry guard.** If `lsp ≠ rsp`, emit nothing and continue. A stroke that is spaced on
160
+ one side and tight on the other is not a dash: it is `--force`, `-5 °C`, a Markdown bullet
161
+ `- item`, a signature `-- Iurii`, or an arrow `->`. This single guard removes the large
162
+ majority of false positives and it applies to both the range and the parenthetical
163
+ branches.
164
+ 5. Let `L` be the index of the code point immediately left of the token's outer spacing
165
+ (`s - 1 - lsp`), and `R` the index immediately right (`e + rsp`). If either index is out of
166
+ range, or `cp[L]` is in `BREAK`, or `cp[R]` is in `BREAK`, emit nothing and continue. A
167
+ dash needs content on both sides on the same line. (This is what protects a list item at
168
+ the start of a line and a trailing `--` at the end of one.)
169
+ 6. **Isolation guard.** If `cp[L]` or `cp[R]` is in `INERT-DASH`, or in `DASH`, or in
170
+ `SPACE` ∪ `NOBREAK-SPACE`, emit nothing and continue.
171
+ Three separate things are being excluded here and all three are load-bearing:
172
+ - an `INERT-DASH` neighbour — U+2011 is produced by `hyphen` at order 35 from text this rule
173
+ has already seen, and U+2012/U+2015/the fullwidth and small forms are each a specialised
174
+ dash with its own reason to exist (§3.1); U+2010 and U+2212 are **not** in this set as of
175
+ spec 0.2.0 — they are ordinary `DASH` members and fall into the next bullet instead;
176
+ - a `DASH` neighbour — the pattern `dash space dash` (`a- - a`). Without this clause the
177
+ rule normalises the second stroke, the two dash runs become adjacent, and on the next
178
+ run they read as **one** run of length 2 with a completely different verdict. That is
179
+ idempotency defect (b), §8.2;
180
+ - a space-like neighbour — `cp[L]` is by construction the code point _beyond_ the outer
181
+ spacing, so a space there means two consecutive space-like characters (a U+00A0 next to
182
+ a U+0020, say — `spaces` cannot produce two U+0020). The spacing of such a token is not
183
+ something this rule should be rewriting.
184
+ 7. **Cluster guard.** Define a **dash cluster** as a maximal span of code points every one of
185
+ which is in `DASH` ∪ `INERT-DASH` ∪ `DIGIT` ∪ `JOINER` (§3.2b — a joiner `ranges` emitted on
186
+ an earlier pass must not split a cluster it sits inside). (Spaces, letters and punctuation all end a
187
+ cluster.) Let `C` be the cluster containing this token's run. **If `C` contains two or more
188
+ maximal runs of `DASH` ∪ `INERT-DASH`, emit nothing for every token in `C`** — the whole
189
+ cluster is inert.
190
+ This covers idempotency defect (a), §8.2: the classification of a range token reads
191
+ `before`/`after`, which may be a dash belonging to a _different_ token, and normalising
192
+ that other token to a spaced form replaces the dash with a space and flips the range
193
+ verdict on the next run.
194
+ `2026-08-15`, `978-3-16-148410-0`, `212-555-1234`, `a—0–0` and `1914-1918—annexation` are
195
+ all single clusters with more than one dash run, and are all inert in their entirety.
196
+ **This guard is not sufficient on its own, and it does not subsume G2.** A cluster ends at
197
+ the first space-like code point, so a _spaced_ token's cluster contains only its own run:
198
+ its `cp[L]` is a digit in a neighbouring cluster and its `before` is a dash in a third one,
199
+ neither of which step 7 can see. That gap is idempotency defect (c), §8.2, and step 8 closes it.
200
+ 8. **Spacing-transition guard (T1).** This guard depends on which replacement form the token's
201
+ branch will choose, so it is evaluated **immediately before emitting**, in §3.3 and §3.4
202
+ alike; it is stated here because it is a property of the token, not of the branch.
203
+
204
+ It applies only when **all** of the following hold:
205
+ - `lsp = rsp = 0` — the token is currently tight; and
206
+ - the chosen form is `em-spaced` or `en-spaced` — the replacement will insert a U+0020 on
207
+ each side.
208
+
209
+ In that case, for each side independently:
210
+ - **left:** if `cp[L]` is in `DIGIT`, let `D` be the maximal `DIGIT` run ending at `L` and
211
+ starting at index `d`. Reading **effective neighbours** (§3.2b) outward from `d`: if the
212
+ first is in `DASH` ∪ `INERT-DASH`, **or** the first is in `SPACE` ∪ `NOBREAK-SPACE` and the
213
+ second is in `DASH` ∪ `INERT-DASH`, emit nothing for this token.
214
+ - **right:** if `cp[R]` is in `DIGIT`, let `D` be the maximal `DIGIT` run starting at `R`
215
+ and ending at index `d`. If `cp[d+1]` is in `DASH` ∪ `INERT-DASH`, **or** `cp[d+1]` is in
216
+ `SPACE` ∪ `NOBREAK-SPACE` and `cp[d+2]` is in `DASH` ∪ `INERT-DASH`, emit nothing.
217
+
218
+ Read plainly: **a tight token must not become spaced when doing so would insert a space
219
+ between itself and a digit run that has another dash on its far side.** That inserted space
220
+ is the only thing this rule can do that changes a _different_ token's `before`/`after` from
221
+ a dash into a space, which is the single remaining way a range verdict can flip between
222
+ runs (§5.3). The two-code-point reach on each side exists because the other dash may be
223
+ tight against the digit run (`a–1-1`) or spaced away from it (`a–1 - 1`); both distances
224
+ have to be caught, and no third distance is reachable, because a `-spaced` replacement
225
+ inserts exactly one U+0020.
226
+
227
+ The guard is deliberately narrow. It never fires when the token is already spaced, never
228
+ when the locale form is `-tight` or `none`, and never when the token's neighbours are
229
+ letters — so `Der Plan--falls es einen gibt--scheitert.` and `Seiten 34-36` are unaffected,
230
+ and `— 1914-1918 годы` still converts its range. What it does cost is written up in §7.4.
231
+
232
+ 9. **Composition guard (T2).** Like T1 this depends on the chosen replacement form, so it is
233
+ evaluated immediately before emitting, in either branch. It exists to discharge the
234
+ composition obligation against `spaces` (order 10) — see
235
+ [pipeline-idempotency.md](pipeline-idempotency.md) §4, defect family 1.
236
+
237
+ If the chosen form is `em-spaced` or `en-spaced`, so that the replacement emits a U+0020 on
238
+ each side, then **emit nothing** if either:
239
+ - `cp[R]` is in **T2's own set** — { U+002C, U+002E, U+003B, U+003A, U+0021, U+003F, U+2026 }
240
+ — or in `CLOSE-BRACKET` = { U+0029, U+005D, U+007D }; or
241
+ - `cp[L]` is in `OPEN-BRACKET` = { U+0028, U+005B, U+007B }.
242
+
243
+ **T2's set is not `spaces`' `STRIP-BEFORE`, and this document used to claim it was.** They
244
+ differ by exactly one code point: U+2026 is in T2's set and is **not** in `STRIP-BEFORE`
245
+ (`spaces.md` §3.1, §3.4). The old claim — "these are precisely the positions from which
246
+ `spaces` deletes a U+0020" — was true when written and became false when U+2026 left
247
+ `STRIP-BEFORE`; a port transcribing the *rationale* rather than the list would convert
248
+ `a--…` to `a – …` in every `-spaced` locale, where the JS implementation declines. Both
249
+ behaviours are idempotent, so no property test separates them: only a fixture can, and one is
250
+ needed (§7.7).
251
+
252
+ **U+2026 stays in T2's set.** T2 is deliberately the wider of the two: its job is to avoid
253
+ emitting a space that something else will remove, and declining one code point more than
254
+ strictly necessary costs a conversion nobody writes (`a--…`) while keeping the guard's
255
+ verdict independent of `spaces`' exact membership — which has now changed once. The two sets
256
+ are related but not equal, and the relationship is stated rather than assumed:
257
+ **T2's set ⊃ `STRIP-BEFORE`, by U+2026.**
258
+
259
+ The positions in `STRIP-BEFORE` and the bracket sets are those from which `spaces` deletes a
260
+ U+0020 (`spaces.md` §3.2
261
+ step 5). Emitting a space there produces text that is a fixed point of _this_ rule and not
262
+ of `spaces`, so the next pipeline pass deletes the space, the token becomes asymmetrically
263
+ spaced, and this rule then declines it — a two-pass divergence:
264
+
265
+ ```
266
+ de-DE: .--. → . – . → . –.
267
+ ```
268
+
269
+ A dash spaced against a full stop, a comma or a bracket is not a construction that occurs in
270
+ real copy, so declining costs nothing. Note that the guard is only needed for the `-spaced`
271
+ forms: a `-tight` form emits no U+0020 at all, and `en-US` (`em-tight`) never triggered the
272
+ defect.
273
+
274
+ ### 3.2a `JOINER` neighbours
275
+
276
+ A `JOINER` adjacent to the token is examined before the branch is chosen:
277
+
278
+ - Let `L*` be `L` moved left across a maximal run of `JOINER`, and `R*` be `R` moved right
279
+ across a maximal run of `JOINER`. If either walk runs off the array, emit nothing.
280
+ - If no joiner was crossed, `L* = L` and `R* = R` and nothing about the rest of the algorithm
281
+ changes.
282
+ - If a joiner **was** crossed and `cp[L*]` and `cp[R*]` are both in `DIGIT`, the token is a
283
+ bound range `ranges` produced on an earlier pass ([ranges.md](ranges.md) §3.3.1): continue with
284
+ `L*`/`R*` in place of `L`/`R`, and extend the token's span to cover the crossed joiners. `dashes`
285
+ itself never reaches this shape as a candidate — extending the span here only ever feeds the
286
+ digit-flanked check that routes the token to `ranges`' exclusive territory (§1, §3.3), never to
287
+ this rule's own parenthetical branch.
288
+ - If a joiner was crossed in any other configuration, **emit nothing**. An author who typed
289
+ U+2060 next to a dash meant it, exactly as with `INERT-DASH` (§3.1).
290
+
291
+ This is what makes a `ranges`-produced binding survive a second pass without a second joiner
292
+ being added, and what keeps this shared scan from rewriting a joiner that only `ranges` — never
293
+ `dashes` — puts there.
294
+
295
+ ### 3.2b `JOINER` is transparent to every lookaround that leaves the token
296
+
297
+ §3.2a makes a joiner transparent to the token that **owns** it. That is not enough, and the gap
298
+ cost an idempotency defect (§8.2, defect (e)). A joiner also sits in the lookaround of a
299
+ **neighbouring** token, where nothing was skipping it.
300
+
301
+ > **Effective neighbour.** For an index `i` and a direction, step outward across any maximal run
302
+ > of `JOINER` and take the first code point that is not a `JOINER`, or `NONE` if the walk leaves
303
+ > the array.
304
+ >
305
+ > **Every guard that inspects a code point outside its own token's dash run reads an effective
306
+ > neighbour, not a raw index.** That is: `before` and `after`, owned by `ranges` since spec 0.5.0
307
+ > ([ranges.md](ranges.md) §3.2-§3.3) — and therefore G1, G2 and G3 — and the two-code-point reach
308
+ > of T1 (§3.2 step 8, shared by both rules). `cp[L]` and `cp[R]` are already joiner-skipping
309
+ > through §3.2a's `L*`/`R*`.
310
+ >
311
+ > **`JOINER` is additionally a member of the cluster alphabet** (§3.2 step 7), so a joiner cannot
312
+ > split a cluster that would otherwise have contained two dash runs.
313
+
314
+ **Why this is forced rather than chosen.** `ranges` (order 25) emits `JOINER`; `dashes` (order 30)
315
+ runs later in the same pipeline pass and consumes the text `ranges` already produced. That is a
316
+ **current cross-rule composition obligation**, not an own-emission one: `dashes`' shared token
317
+ guards and lookarounds must be invariant under `JOINER`, so a verdict this rule reaches does not
318
+ depend on whether an earlier `ranges` pass has already bound the range sitting next to it. A guard
319
+ that could not see through a neighbouring `ranges` token's joiners would read a bound range's
320
+ `JOINER dash JOINER` as something other than the plain dash it wraps, and reach a different
321
+ verdict than it would against the same input before `ranges` ran — which is exactly the
322
+ composition failure [pipeline-idempotency.md](pipeline-idempotency.md) §4 and §5 forbid between
323
+ two rules in one pipeline. **Before the spec 0.5.0 split, when range handling was still part of
324
+ this document's own algorithm, the identical requirement was instead an *own-emission* CO-S
325
+ obligation** ([pipeline-idempotency.md](pipeline-idempotency.md) §5.1a) — the single rule had to
326
+ be inert to its own `JOINER` output, on the same footing it had to be inert to its own dash
327
+ output. That ownership is historical now (§8.4 records it); the shape of the requirement did not
328
+ change when the rule that emits `JOINER` did. §3.2a supplies this reading
329
+ for the token that sits next to a joiner; §3.2b supplies it for every other guard's lookaround,
330
+ and the two together are the complete current statement of `dashes`' joiner-transparent reading
331
+ behaviour.
332
+
333
+ ---
334
+
335
+ ### 3.3 Range branch — retired here, moved to `ranges` (spec 0.5.0)
336
+
337
+ **This section described the range branch through spec 0.4.1. As of spec 0.5.0, range detection
338
+ is [ranges.md](ranges.md) §3.2-§3.3.1 in full** — the admissibility test (`cp[L]`/`cp[R]` both
339
+ `DIGIT`), guards G1-G5, the replacement table, and binding a tight range (§3.3.1) all moved there
340
+ verbatim, still reading the same `dash.range` locale field under the same key.
341
+
342
+ **What stays true here, restated because it is `dashes`' own contract now rather than a
343
+ consequence of one rule's two branches:** a digit-flanked dash token (`cp[L]` and `cp[R]` both
344
+ `DIGIT`) is never processed by `dashes` — not converted, not declined-and-then-reconsidered,
345
+ simply never reached. This holds **unconditionally**, whether `ranges` is enabled or not (§1).
346
+ `ranges` disabled does not mean the token falls back to parenthetical treatment; it means nothing
347
+ in the pipeline touches it at all, and `5-10`, `Figure 5-10`, `9-11` and `7-11` are all
348
+ byte-identical no-ops with default options.
349
+
350
+ The section number is kept, not renumbered away, for the same reason §3.2 step 2a keeps its
351
+ number after being retired: several documents (this one's own §4, §6, §7; `ranges.md`;
352
+ `pipeline-idempotency.md`) cite sections of this document by number, and a retired section that
353
+ still occupies its number costs less than a renumbering would.
354
+
355
+ ---
356
+
357
+ ### 3.4 Parenthetical branch
358
+
359
+ Reached for every token this rule sees — a digit-flanked token (both `cp[L]` and `cp[R]` in
360
+ `DIGIT`) is never handed to `dashes` at all (§3.3, §1), not merely excluded from this branch, so
361
+ "is not a range candidate" (i.e. at least one of `cp[L]`, `cp[R]` is not a `DIGIT`) is true of
362
+ every token that reaches here by construction. Additional guards:
363
+
364
+ - **P5 (spec 0.6.0) — authored en-dash mark-identity veto.** If `k = 1` and `cp[s]` is U+2013,
365
+ emit nothing — **unconditionally**: every locale, tight or spaced, regardless of
366
+ `dash.parenthetical`'s target glyph. A run mixing U+2013 with any other `DASH` member (`a-–b`,
367
+ `k = 2` or `3`) is **not** covered and still promotes under P2/P3 below, exactly as before.
368
+
369
+ This is the one narrow exception to P2's "no distinction based on which `DASH` glyph the
370
+ author typed": a pure, single, authored en-dash is treated as carrying its own mark identity —
371
+ interruption (em-dash) and connection/range (en-dash) are two established, different
372
+ conventional roles, not merely two lengths of the same mark — and is never silently rewritten
373
+ into a different mark, in any locale, including one whose own `dash.parenthetical` target
374
+ glyph is en-dash (there, only the *spacing* of an authored en-dash may still be corrected; the
375
+ *glyph* is never touched by any path in this rule, so the distinction is moot but the veto
376
+ still applies structurally, for the same input-invariance reason P4 declines rather than
377
+ reasons about the locale's own target).
378
+
379
+ **Full argument for why this does not reopen the guards §8.1 records retiring — fresh M4
380
+ evidence, the narrower scope, and why it is unconditional across every locale rather than
381
+ keyed to the target glyph — is §8.8.**
382
+ - **P1 — a bare hyphen-shaped stroke must be spaced.** If `k = 1` and `cp[s]` is U+002D,
383
+ U+2010 or U+2212 and `lsp = 0`, emit nothing. This is the compound-word guard: `well-known`,
384
+ `e-mail`, `Jean-Luc`, `по-русски`, `well‐known` (U+2010) and `a−b` (U+2212, the same tight
385
+ attached shape) are all untouched. U+2013/U+2014 are never a compound-word substitute — no
386
+ author writes a compound word with a real en or em dash — so this guard never covers them.
387
+ (`lsp = rsp` by §3.2 step 4, so testing one side suffices.)
388
+ - **P2 (restored, spec 0.2.0) — an en or em dash is a parenthetical dash, promoted to the
389
+ locale's form exactly as a hyphen would be.** Spec 0.1.0's step 2a made every token holding
390
+ U+2013/U+2014 unreachable in this branch, which made this clause dead code, so it was deleted
391
+ rather than left to rot. 0.2.0 retired step 2a outright (its history note explains why), so
392
+ U+2013/U+2014 reach this branch again on the same terms as U+002D, and the clause is restored
393
+ under its original label and its original, unconditional wording: length and spacing are both
394
+ corrected, with no distinction based on which `DASH` glyph the author typed.
395
+ - **P4 — Roman-numeral veto.** If `lsp = rsp = 0` (the token is tight), and the maximal run of
396
+ `ROMAN` code points ending at `cp[L]` is non-empty, and the maximal run of `ROMAN` code points
397
+ starting at `cp[R]` is non-empty, and each of those runs is bounded on its outer side by a
398
+ code point that is not in `LETTER`, then **emit nothing**.
399
+
400
+ A tight dash between two Roman numerals is a **range** — `в XV—XVII веках`, `Louis XIV—XVI` —
401
+ and Russian sets ranges with a tight em dash, which is exactly the form already present. The
402
+ range branch cannot see it, because §3.3 admits a range candidate only when a `DIGIT` stands
403
+ on each side; so without this veto the token falls through to the parenthetical branch and an
404
+ `em-spaced` or `en-spaced` locale **damages correct input**: `в XV—XVII веках` becomes
405
+ `в XV — XVII веках`. That is the worst category of defect in this project — not a missed
406
+ improvement but a corruption of text that was already right — and the `ru` locale file's own
407
+ citation uses that very phrase as its range example.
408
+
409
+ P4 is a **veto, not a range-enabler**: it declines, and never converts anything. Making
410
+ `ROMAN` runs into full range candidates would let `XV-XVII` (typed with a hyphen) become a
411
+ proper range, but it would also fire on ordinary all-caps words built only from Roman letters
412
+ — `VIVID`, `CIVIL`, `MIX`, `DID` — and converting is the direction that can damage. §7.2
413
+ records the miss.
414
+
415
+ Lower-case is excluded for the same reason: `mix—did` must still normalise, and lower-case
416
+ Roman numerals in running prose are vanishingly rare next to lower-case words made of the same
417
+ letters.
418
+
419
+ - **P3 — a run of 2 or 3 qualifies regardless of spacing**, subject to §3.2 step 4 having
420
+ already required the spacing to be symmetric. `word--word` and `word -- word` both qualify;
421
+ `--force` does not (asymmetric); `---` used as a Markdown thematic break does not (it sits
422
+ alone on its line, so §3.2 step 5 rejects it for want of content on both sides).
423
+
424
+ Emit one edit replacing `cp[s - lsp … e - 1 + rsp]` with:
425
+
426
+ | `dash.parenthetical` | replacement |
427
+ | -------------------- | ------------------------------------------------------- |
428
+ | `"em-tight"` | U+2014 |
429
+ | `"em-spaced"` | U+0020 U+2014 U+0020 |
430
+ | `"en-tight"` | U+2013 |
431
+ | `"en-spaced"` | U+0020 U+2013 U+0020 |
432
+ | `"none"` | _(emit nothing — the token is left exactly as written)_ |
433
+
434
+ **Subject to guards T1 and T2 (§3.2 steps 8 and 9), evaluated now that the form is known.** If the replacement is identical to the span it replaces, emit
435
+ nothing.
436
+
437
+ ### 3.5 Continue, and the non-overlap guarantee
438
+
439
+ Set `i` to the index just past the token's run in the **input** array and continue from §3.2
440
+ step 1. Because every edit is computed against input indices and the pipeline applies edits
441
+ afterwards, no index arithmetic on a partially rewritten array is ever required.
442
+
443
+ An edit's span is `cp[s - lsp … e - 1 + rsp]`, which includes the outer spacing. Two such
444
+ spans could in principle overlap: if two tokens are separated by exactly one space, that one
445
+ space is the first token's `rsp` and the second token's `lsp`, both spans claim it, and the
446
+ pipeline sees overlapping edits — which is a `POLYTYPO_RULE_CONTRACT` violation, not a
447
+ merge.
448
+
449
+ **This cannot happen after §3.2 step 6.** Overlap requires `rsp₁ = 1`, `lsp₂ = 1`, and the
450
+ shared space at index `e₁ = s₂ - 1`. But then the second token's `cp[L]` is `cp[s₂ - 2] =
451
+ cp[e₁ - 1]`, the last code point of the first token's run, which is in `DASH` — so step 6
452
+ makes the second token inert. Symmetrically the first token's `cp[R]` is the second token's
453
+ first dash, also in `DASH`, so it too is inert. The `dash space dash` shape produces **no
454
+ edits at all**, and every pair of emitted spans is therefore separated by at least one code
455
+ point that is neither a dash nor a claimed space.
456
+
457
+ **Normative fallback.** If an implementation nevertheless computes two overlapping spans, the
458
+ leftmost token wins and the later token is discarded — the same first-claim-wins policy
459
+ `nbsp` uses (see `nbsp.md` §3.2). This is stated so that behaviour is defined rather than
460
+ undefined; it is unreachable under the algorithm as written, and an implementation that finds
461
+ it reachable has found a bug in this document and should report it rather than rely on the
462
+ fallback.
463
+
464
+ ### 3.6 No-break spaces are never touched, and never need preserving
465
+
466
+ A no-break space adjacent to a dash makes the token inert (§3.2 step 3 + step 6), so this rule
467
+ never edits a span containing one and there is nothing to preserve. **Earlier revisions of this
468
+ document carried a preservation clause here** — the emitted replacement was to carry a
469
+ pre-existing U+00A0 across rather than substitute U+0020 — and it existed only because those
470
+ revisions counted a no-break space as spacing and therefore _did_ edit such tokens. With that
471
+ gone the clause is vacuous, and vacuous clauses in a spec are a liability: an implementer who
472
+ finds one either writes dead code or invents a case to justify it.
473
+
474
+ The Russian case the clause was written for still comes out right, by a shorter route. Pass 1
475
+ converts `Москва - столица` to `Москва — столица` (`em-spaced`, both sides U+0020); `nbsp` then
476
+ promotes the leading space to U+00A0 because `ru` lists U+2014 in `nbsp.beforePunctuation`,
477
+ giving `Москва⍽— столица`. On the next pass this rule finds `lsp = 0`, `rsp = 1`, declines at
478
+ the symmetry guard, and emits nothing. Same output, reached by declining rather than by
479
+ recomputing an identical span.
480
+
481
+ **Every space in an emitted replacement is therefore U+0020 and nothing else.** A `-spaced`
482
+ form emits U+0020 on each side, a `-tight` form emits none, and no other _space_ character can
483
+ appear in an edit this rule produces. **This rule's current emission alphabet is exactly U+2013
484
+ or U+2014 — the target dash glyph itself — plus, for a spaced form, U+0020 on each side; nothing
485
+ else.** In particular it emits no U+2060. Through spec 0.4.1, a converted *tight* range dash could
486
+ additionally carry U+2060 immediately before and after the emitted dash glyph (§3.3.1, pre-0.5.0);
487
+ range binding — and every U+2060 this rule's output can now contain — is exclusively `ranges`'
488
+ behaviour ([ranges.md](ranges.md) §3.3.1) as of spec 0.5.0, since `dashes` never processes the
489
+ digit-flanked token a binding would apply to in the first place (§1). `dashes` still *reads*
490
+ through an adjacent run of joiners a neighbouring `ranges` token produced (§3.2a, §3.2b), which is
491
+ a different thing from emitting one itself.
492
+
493
+ ---
494
+
495
+ ## 4. Must not touch
496
+
497
+ **Scope.** Per [pipeline-idempotency.md](pipeline-idempotency.md) §5.2 each bullet is **[P]** —
498
+ a guarantee of `transform` as a whole — or **[R]** — true of this rule in isolation but capable
499
+ of being falsified by another rule, which is then named.
500
+
501
+ - **[P] The hyphen in a compound word.** `well-known`, `e-mail`, `Jean-Luc`, `well-being`,
502
+ `из-под`, `кое-что`, `-таки`. Guard P1.
503
+ - **[P] A leading or trailing hyphen with asymmetric spacing:** `--force`, `-v`, `- item`
504
+ (Markdown/YAML list), `-- signature`, `->`, `<-`. Guard §3.2 step 4.
505
+ - **[P] A Markdown thematic break or setext underline** (`---`, `----`, a line of hyphens):
506
+ rejected by §3.2 step 2 (`k > 3`) or step 5 (no content on both sides).
507
+ - **[P] ISO dates** `2026-08-15`, **ISBNs**, **phone numbers**, **part numbers**: digit-flanked,
508
+ so they never reach `dashes` at all as of spec 0.5.0 (§1, §3.3). When `ranges` is explicitly
509
+ enabled, its own guards G2 (chain) and G4 (equal digit count) additionally decline them on
510
+ their own terms ([ranges.md](ranges.md) §3.2, §6).
511
+ - **[P] `COVID-19`, `MP3-4`, `Windows-1252`, `ISO 8859-1`, `UTF-8`**: `COVID-19`'s hyphen is
512
+ letter-flanked (`cp[L]` is a letter, so P1's compound-word guard applies); the other four are
513
+ digit-flanked and never reach `dashes` at all as of spec 0.5.0 (§1). `ranges.md` §3.2's G1/G4
514
+ additionally decline the digit-flanked ones on their own terms when `ranges` is explicitly
515
+ enabled.
516
+ - **[P] A tight dash between two Roman numerals**, in either direction: `XV—XVII`, `I—V`,
517
+ `Louis XIV—XVI`. Guard P4. Note this is a _preservation_ claim, not a conversion one — the
518
+ input is already correct and the rule's job is to leave it alone.
519
+ - **[P] Any member of `INERT-DASH`** (spec 0.2.0): U+00AD, U+2011, U+2012, U+2015, and the
520
+ fullwidth/small forms U+FE58, U+FE63, U+FF0D. Neither read as a candidate nor produced. **Not
521
+ a member since 0.2.0: U+2010 and U+2212 are ordinary `DASH` candidates** (§3.1) — `x ‐ y`
522
+ (U+2010) and `x − y` (U+2212) both convert to the locale's parenthetical form. `1990−2000` is
523
+ digit-flanked, so it never reaches `dashes`' parenthetical form at all — with `ranges`
524
+ explicitly enabled it converts and binds as a range there (ranges.md §3.3, §3.3.1).
525
+ - **[P] A negative number.** `-5` has no space to the left of the digit and a letter/space to
526
+ the left of the hyphen → asymmetric → rejected. This holds for U+002D, U+2010 and U+2212
527
+ alike — the same asymmetry guard, not a glyph-specific one.
528
+ - **[P] Every digit-flanked stroke, unconditionally** — `5-10`, `Figure 5-10`, `9-11`: never
529
+ reached by `dashes` at all, let alone reinterpreted as parenthetical (§1, §3.3). This holds
530
+ regardless of whether `ranges`' own guards would have accepted or declined the token —
531
+ `dashes` does not evaluate them and does not need to.
532
+ - **[P] URLs, code spans, fenced code, HTML attributes.** Removed by the mode adapter before this
533
+ rule sees them. This rule has no notion of a URL and must not grow one.
534
+ - **[P] Line terminators**, which are never inserted, deleted or crossed.
535
+
536
+ ---
537
+
538
+ ## 5. Idempotency argument
539
+
540
+ Write `T` for `dashes`. `T` edits only **parenthetical dash tokens**: at least one of `cp[L]`,
541
+ `cp[R]` is not `DIGIT` (§1, §3.3) — a digit-flanked token is never reached by `T` on any pass, so
542
+ nothing below needs to reason about one. Each edit `T` makes replaces a span consisting of one
543
+ maximal `DASH` run plus at most one space-like code point on each side, with a span of the same
544
+ shape (`space? dash space?`). So every edit is one of:
545
+
546
+ - **(E1)** replace one dash with another dash at the run's position;
547
+ - **(E2)** shorten or lengthen the run (`--` → `—`), without creating a space;
548
+ - **(E3)** delete a U+0020 adjacent to the run (producing a `-tight` form);
549
+ - **(E4)** insert a U+0020 adjacent to the run (producing a `-spaced` form).
550
+
551
+ `T` never touches a letter, a digit, a full stop, a comma, a solidus, a line terminator, or any
552
+ space not immediately adjacent to a dash run. §3.5 establishes that emitted spans are pairwise
553
+ disjoint, so no edit's output can be reinterpreted as part of a different edit's span on the same
554
+ pass.
555
+
556
+ ### 5.1 Emitted forms are fixed points
557
+
558
+ | Emitted form | Re-read as | Recomputed replacement |
559
+ | ---------------------------------------- | ------------------------- | ------------------------ |
560
+ | `X` tight (`em-tight`/`en-tight`) | `k = 1`, `lsp = rsp = 0` | same single code point |
561
+ | `␣X␣` spaced (`em-spaced`/`en-spaced`) | `k = 1`, `lsp = rsp = 1` | same three code points |
562
+ | nothing (`"none"`, or a declined token) | unchanged input | nothing |
563
+
564
+ A tight form is re-admitted by P2 (`k = 1`, and the glyph is a `DASH` member — spec 0.2.0 gives
565
+ U+2013/U+2014 no token-level special case, §3.2 step 2a, so this holds regardless of which `DASH`
566
+ glyph the token holds). A spaced form is re-admitted the same way, with `lsp = rsp = 1` read back
567
+ by §3.2 step 3. A token `nbsp` has since promoted (`ru`: U+00A0 before an em dash) is not
568
+ recomputed at all — §3.2 step 3 makes a no-break-space neighbour space-like, so the isolation
569
+ guard (step 6) declines it and nothing is emitted (§3.6). A digit-flanked token stays outside `T`'s
570
+ domain on every pass, by construction, so it is trivially a fixed point of `T` regardless of what
571
+ `ranges` does to it.
572
+
573
+ ### 5.2 A declined token stays declined
574
+
575
+ For a token `T'` that `T` declines, its verdict depends only on: `k` and the run's position;
576
+ `lsp`, `rsp`; `cp[L]`, `cp[R]`; the dash cluster containing its run (§3.2 step 7); the `ROMAN`
577
+ runs P4 reads; the run's own code point, which P5 reads; and, when relevant, the digit run T1
578
+ reads. None of these can be changed by an edit `T` itself makes elsewhere in the same pass:
579
+
580
+ - **The run's own code point (P5, spec 0.6.0).** P5's verdict — `k = 1` and `cp[s] = U+2013` —
581
+ reads only the run's own single code point, which by the same disjoint-spans argument as `k`
582
+ above sits outside every edit span and so is byte-identical on every pass. A token P5 declines
583
+ stays declined on exactly the same footing as P4: the input P5 reads is one this rule's own
584
+ edits cannot produce or destroy (no edit's replacement contains U+2013 anywhere in this rule's
585
+ emission alphabet — §3.6 — so a *new* pure-U+2013 run this rule itself created is not even a
586
+ reachable state to worry about).
587
+
588
+ - **The run and its spacing (`k`, `lsp`, `rsp`).** `T'`'s run sits outside every edit span
589
+ (spans are pairwise disjoint, §3.5). `lsp`/`rsp` probe `cp[s-1]`/`cp[e]`; if either lay inside
590
+ another edited token's span, the two tokens would be adjacent or separated by exactly one
591
+ shared space — both shapes the isolation guard (step 6) already declines (a `DASH` or
592
+ space-like neighbour), so neither arises for a token that reaches a verdict at all.
593
+ - **`cp[L]`, `cp[R]`.** If `cp[L]` lies inside an edited token's span, it is a dash or an outer
594
+ space of that token — either way step 6 already declined `T'` (it rejects both `DASH` and
595
+ space-like at that position), and E1–E4 never put a letter or digit there, so `T'` is declined
596
+ again on the next pass, for the same reason. The mirror argument gives `cp[R]`.
597
+ - **The cluster.** A cluster with ≥2 dash runs emits nothing at all (step 7), so it is
598
+ byte-identical next pass and stays inert. A cluster with exactly one dash run may be edited,
599
+ but the edit cannot merge it with a neighbouring cluster except by deleting a space (E3), and a
600
+ merge only ever adds dash runs — which can only make more tokens inert, never fewer. It cannot
601
+ split a live cluster into something revivable (E4 turns a one-run cluster into one-run pieces),
602
+ and it cannot split an inert cluster at all, since an inert cluster emits nothing. No token
603
+ moves from inert to live via the cluster guard.
604
+ - **`ROMAN` adjacency (P4).** No edit touches a letter, so the `ROMAN` runs flanking a token are
605
+ untouched by any of `T`'s own edits.
606
+ - **The digit run T1 reads.** No edit touches a digit, so the digit run itself is stable. T1's
607
+ concern is not this token's own recomputation but a *neighbouring* token's verdict — see §5.3.
608
+
609
+ Every input to a declined token's verdict is therefore unaffected by any edit `T` makes, on every
610
+ pass: `T(T(x))` declines exactly where `T(x)` declined. The historical counterexamples that led to
611
+ this guard set — before it closed every gap — are summarised in §8.2.
612
+
613
+ ### 5.3 Interaction with `ranges` is stable
614
+
615
+ `ranges` (order 25) runs before `dashes` (order 30) in every pass. Three guarantees hold between
616
+ them, and all three are current:
617
+
618
+ - **`dashes` emits only U+2013 or U+2014, plus zero or one U+0020 on each side — never U+2060.**
619
+ Range binding ([ranges.md](ranges.md) §3.3.1) is exclusively `ranges`' emission; an interrupting
620
+ parenthetical dash is exactly where a line *may* break, so there is nothing for `dashes` to bind
621
+ (§3.6).
622
+ - **A digit-flanked token is never reinterpreted as parenthetical, on any pass.** The `isDigit`
623
+ test that routes a token to `ranges` instead of `dashes` (§1, §3.3) is evaluated after the
624
+ joiner-crossing walk (§3.2a), so a token `ranges` bound on an earlier pass — where the walk
625
+ re-enters across the `JOINER` pair and finds `DIGIT` on both effective neighbours — presents to
626
+ `dashes` with digit-flanked `leftCp`/`rightCp` exactly as an unbound one would, and `dashes`
627
+ declines it on the same unconditional test. `dashes` cannot strip a binding it never
628
+ reconsiders, and cannot create one, since it never emits `JOINER`.
629
+ - **`dashes`' own edits cannot flip a *neighbouring* range token's guard verdict on the next
630
+ pass, even though `dashes` itself never reads that token's guards.** The one edit shape that
631
+ could — a tight token becoming spaced, inserting a U+0020 next to a digit run that has another
632
+ dash on its far side — is exactly what the spacing-transition guard (T1, §3.2 step 8) declines.
633
+ This is `dashes`' composition obligation *toward* `ranges`, symmetric to the rule-order argument
634
+ in [ranges.md](ranges.md) §4: `ranges` must not see an adjacency `dashes` disturbed, and T1 is
635
+ how `dashes` upholds that on every subsequent pass. The full historical derivation of why this
636
+ is the only verdict input `dashes` can perturb — and the defects that motivated T1 and the
637
+ cluster guard — is §8.2 and §8.3.
638
+
639
+ ### 5.4 Composition obligations
640
+
641
+ Per [pipeline-idempotency.md](pipeline-idempotency.md) §5. This rule is R₃, so the obligation runs
642
+ against `spaces` (R₁) and `ellipsis` (R₂), and it must itself survive every later rule.
643
+
644
+ **What this rule emits.** U+2013 or U+2014 at a dash run's position; zero or one U+0020
645
+ immediately on each side. It deletes only U+0020 and dash code points. It never emits a no-break
646
+ space, a letter, a digit, a full stop, or U+2060.
647
+
648
+ **Against `spaces` (I₁).** The only rule before `hyphen` that emits U+0020, so a `-spaced` form
649
+ could place a space where `spaces` deletes one — guard T2 (§3.2 step 9) exists for exactly this.
650
+ A run of two or more spaces is structurally impossible: the replacement emits exactly one U+0020
651
+ per side, and step 6 guarantees the code point beyond it is not space-like.
652
+
653
+ **Against `ellipsis` (I₂).** Discharged trivially: this rule never emits U+002E or U+2026, and
654
+ every deletion is a U+0020 or a dash adjacent to the token — never a code point standing between
655
+ two dot runs.
656
+
657
+ **What must be preserved *for* this rule.** `nbsp` (R₈)'s entire emission alphabet is U+00A0 and
658
+ U+202F, both of which make an adjacent dash token inert (§3.2 step 3 + step 6) — so `nbsp` cannot
659
+ create a dash token, alter a spacing verdict, or revive a declined one. This is condition **CO-S**
660
+ in [pipeline-idempotency.md](pipeline-idempotency.md) §5.1a, discharged structurally rather than
661
+ case by case. `hyphen` (R₄) converts some U+002D to U+2011: no verdict changes, because a U+002D
662
+ `hyphen` claims is intra-word and `dashes` had already declined it under P1, and because step 6
663
+ and the cluster alphabet treat `DASH` and `INERT-DASH` alike. `quotes` (R₅), `apostrophe` (R₆) and
664
+ `symbols` (R₇) emit characters in none of this rule's classes and never change spacing. `ranges`
665
+ (order 25, runs earlier in the same pass) is covered by §5.3 above.
666
+
667
+ ### 5.5 Conclusion
668
+
669
+ Every input to every verdict — declined or emitted — is stable across a re-run of `dashes` alone
670
+ (§5.1, §5.2), stable under `ranges`' own edits and vice versa (§5.3), and stable under every other
671
+ rule in the pipeline (§5.4). Hence `T(T(x)) = T(x)`, discharging `dashes`' contribution to the
672
+ pipeline-level fixed point [pipeline-idempotency.md](pipeline-idempotency.md) §2 requires. ∎
673
+
674
+ ---
675
+
676
+ ## 6. Worked examples
677
+
678
+ `␣` = U+0020, `⍽` = U+00A0, `⟨J⟩` = U+2060 (word joiner), `⟶` = no change.
679
+
680
+ **A row containing an invisible code point must be checked in the escaped mirror, never read.**
681
+ U+2060 is zero-width: a range row that omits it looks _exactly_ right on screen and is wrong at
682
+ the byte level — the same caution [ranges.md](ranges.md) §6 states for its own rows, which is
683
+ where every range row (formerly numbered 3, 3a–3d, 3j, 13 and 17 in this table) now lives. `⟨J⟩`
684
+ is a notation used only in these two tables; the fixture files carry the real U+2060, and
685
+ `spec/fixtures/.escaped/` is the only rendering in which its presence or absence can actually be
686
+ seen. The same applies to any future rule emitting U+00AD, U+200B or a variation selector.
687
+
688
+ **As of spec 0.5.0, this table contains `dashes`' own behaviour only — parenthetical dashes.**
689
+ Every digit-flanked input below shows this rule's current, actual behaviour: no change at all,
690
+ because `dashes` never processes a digit-flanked token (§1, §3.3). Row numbers 3, 3a–3j, 13 and
691
+ 17 are kept exactly where they were — not renumbered, not deleted — because fixtures and other
692
+ documents cite them by number; each now points to [ranges.md](ranges.md) §6, which is the current
693
+ normative source for what happens to that same input when `ranges` is explicitly enabled.
694
+
695
+ ### `en-US` — `parenthetical: "em-tight"` (`range` is now [ranges.md](ranges.md)'s field, `"en-tight"`, not this rule's)
696
+
697
+ | # | Input | Output | Why |
698
+ | --- | --------------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
699
+ | 1 | `The plan␣-␣if there is one␣-␣fails.` | `The plan—if there is one—fails.` | P1 satisfied (spaced), `em-tight` collapses the spacing |
700
+ | 2 | `The plan--if there is one--fails.` | `The plan—if there is one—fails.` | P3, tight both sides |
701
+ | 3 | `1914-1918 and pp. 34-36` | ⟶ | digit-flanked on both sides of both tokens — never reaches `dashes` at all (§1, §3.3); see [ranges.md](ranges.md) §6 row 3 for what `ranges` does with this input when explicitly enabled |
702
+ | 3a | `Takes 5-10 days` | ⟶ | digit-flanked — see [ranges.md](ranges.md) §6 row 3a |
703
+ | 3b | `aged 9-10 years` | ⟶ | digit-flanked — see [ranges.md](ranges.md) §6 row 3b |
704
+ | 3c | `chapters 1-12` | ⟶ | digit-flanked — see [ranges.md](ranges.md) §6 row 3c |
705
+ | 3d | `0-60 in six seconds` | ⟶ | digit-flanked — see [ranges.md](ranges.md) §6 row 3d |
706
+ | 3e | `won 10-7` | ⟶ | digit-flanked; also declined by `ranges` itself when enabled — see [ranges.md](ranges.md) §6 row 3e |
707
+ | 3f | `code 9-05` | ⟶ | digit-flanked; also declined by `ranges` itself when enabled — see [ranges.md](ranges.md) §6 row 3f |
708
+ | 3g | `Call 555-1234` | ⟶ | digit-flanked; also declined by `ranges` itself when enabled — see [ranges.md](ranges.md) §6 row 3g |
709
+ | 3h | `Call 1-800 now` | ⟶ | digit-flanked; also declined by `ranges` itself when enabled — see [ranges.md](ranges.md) §6 row 3h |
710
+ | 3i | `the 2020-24 season` | ⟶ | digit-flanked; also declined by `ranges` itself when enabled — see [ranges.md](ranges.md) §6 row 3i |
711
+ | 3j | `Figure 5-10` | ⟶ | digit-flanked — never reaches `dashes` (§1, §3.3), so this compound label is a byte-identical no-op with default options. See [ranges.md](ranges.md) §5 and §6 row 3j for the documented, opt-in-only limitation |
712
+ | 4 | `A well-known e-mail address` | ⟶ | P1: bare hyphen, unspaced |
713
+ | 5 | `COVID-19 and ISO 8859-1` | ⟶ | `COVID-19`: `cp[L]` is a letter, P1's compound-word guard declines it. `ISO 8859-1`: digit-flanked — never reaches `dashes` (§1, §3.3); see [ranges.md](ranges.md) §6 row 5 for what `ranges` does with the digit-flanked token when explicitly enabled (G4 rejects it there, 4 vs 1) |
714
+ | 6 | `Released 2026-08-15, ISBN 978-3-16-148410-0` | ⟶ | digit-flanked — never reaches `dashes` (§1, §3.3); see [ranges.md](ranges.md) §6 row 6 (G2 chain rejects both there when `ranges` is enabled) |
715
+ | 7 | `Call 212-555-1234` | ⟶ | digit-flanked — never reaches `dashes` (§1, §3.3); see [ranges.md](ranges.md) §6 row 7 (G2 chain rejects it there when `ranges` is enabled) |
716
+ | 8 | `run --force to override` | ⟶ | asymmetric spacing |
717
+ | 9 | `- first item` | ⟶ | no content to the left on the line |
718
+ | 10 | `Scores: 20-10` | ⟶ | digit-flanked — never reaches `dashes` (§1, §3.3); see [ranges.md](ranges.md) §6 row 10 (G5 rejects it there when `ranges` is enabled, 20 > 10) |
719
+
720
+ ### `de-DE` — `parenthetical: "en-spaced"` (`range` is now [ranges.md](ranges.md)'s field, `"en-tight"`, not this rule's)
721
+
722
+ | # | Input | Output | Why |
723
+ | --- | --------------------------------------------- | --------------------------------------------- | ------------------------------------------------------ |
724
+ | 11 | `Der Plan--falls es einen gibt--scheitert.` | `Der Plan␣–␣falls es einen gibt␣–␣scheitert.` | `en-spaced` inserts the spacing the tight input lacked |
725
+ | 12 | `Der Plan␣–␣falls es einen gibt␣–␣scheitert.` | ⟶ | already the target form |
726
+ | 13 | `Seiten 34-36` | ⟶ | digit-flanked — never reaches `dashes` (§1, §3.3); see [ranges.md](ranges.md) §6 for what `ranges` does with this input when explicitly enabled |
727
+
728
+ ### `ru` — `parenthetical: "em-spaced"` (`range` is now [ranges.md](ranges.md)'s field, `"em-tight"`, not this rule's)
729
+
730
+ | # | Input | Output | Why |
731
+ | --- | ------------------------------ | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
732
+ | 14 | `Москва␣-␣столица` | `Москва␣—␣столица` | `em-spaced` |
733
+ | 15 | `Москва⍽—␣столица` | ⟶ | declined by the **symmetry guard** (`lsp = 0`, since a U+00A0 is not this rule's spacing; `rsp = 1`) — unaffected by step 2a's retirement, because the symmetry guard has never read which `DASH` glyph the token holds |
734
+ | 16 | `из-под стола, кое-что` | ⟶ | P1. (`hyphen`, order 35, will bind these two hyphens with U+2011 — that is a different rule) |
735
+ | 17 | `Годы 1941-1945 были тяжёлыми` | ⟶ | digit-flanked — never reaches `dashes` (§1, §3.3); see [ranges.md](ranges.md) §6 row 17 for what `ranges` does with this input when explicitly enabled (Russian sets an em dash in ranges, `range: "em-tight"`) |
736
+ | 17a | `в XV—XVII веках` | ⟶ | **guard P4.** A tight em dash between two Roman-numeral runs is a range already in its correct form. Previously produced `в XV — XVII веках`, damaging input that was already right |
737
+ | 17b | `в XV-XVII веках` | ⟶ | P4 fires here too (it tests the token's spacing and its neighbours, not which dash was typed), so the hyphen is left as written rather than converted. A miss, not damage — §7.2 |
738
+ | 17c | `Москва—столица` | `Москва␣—␣столица` | Cyrillic is not `ROMAN`, so P4 does not fire. ru's parenthetical is `em-spaced`; the token's length already matches (`em`), so only its spacing changes — but a length mismatch would convert here too, since spec 0.2.0 retired the token-level distinction entirely (§3.2 step 2a) |
739
+
740
+ ### Idempotency regression cases
741
+
742
+ These are the shipped defects from §8.2. Both are now "no change" cases, and both must be
743
+ fixtures.
744
+
745
+ **Spec 0.5.0 note.** Every row below has been re-verified against `dashes`' own current output
746
+ (default options — `ranges` disabled), not the old combined rule's. Every one is still "no
747
+ change", but for a digit-flanked row (18, 20, 24, 25) that involves a would-be range token, the
748
+ reason is no longer "the shared guards evaluate and reject it" — with `ranges` off, that token is
749
+ simply never looked at by anything. The output these rows assert is unaffected; the "Why" column's
750
+ reasoning about a digit-flanked sub-token predates the split and should be read as "and, if
751
+ `ranges` is also enabled, its own G-guards independently reject the same token" rather than as a
752
+ claim about default-options behaviour.
753
+
754
+ | # | Locale | Input | Output | Why |
755
+ | --- | ------- | ----------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
756
+ | 18 | `ru` | `a—0–0` | ⟶ | §3.2 step 7: one cluster (`—`,`0`,`–`,`0`) with two dash runs → entirely inert. Previously converged only after two runs |
757
+ | 19 | `en-US` | `a-␣-␣a` | ⟶ | §3.2 step 6: the second token's `cp[L]` is `-`, in `DASH`. Previously produced `a-—a` and then `a—a` |
758
+ | 20 | `de-DE` | `1914-1918--annexation` | ⟶ | one cluster, three dash runs. Previously the `--` became `␣–␣` on run 1 and the range fired on run 2 |
759
+ | 21 | `en-US` | `a␣-␣-␣b` | ⟶ | both tokens see a `DASH` at `cp[L]` or `cp[R]`; also the shape that would have produced overlapping edit spans (§3.5) |
760
+ | 24 | `de-DE` | `a–1␣-␣1` | ⟶ | **defect (c), §8.2 (historical).** Step 8 (shared): the `–` is tight, `en-spaced` would make it spaced, and the digit run `1` to its right has a `-` two code points beyond → the `–` is inert. The `-` was rejected by `ranges`' own G2 (`before` is `–`) at the time this row was written, pre-0.5.0. Previously `a – 1 - 1` then `a – 1–1` |
761
+ | 25 | `ru` | `a–1␣-␣1` | ⟶ | same shape, `em-spaced` parenthetical and `em-tight` range. Previously `a — 1 - 1` then `a — 1—1` |
762
+ | 26 | `de-DE` | `a–1-1` | ⟶ | the tight sibling of case 24, but **step 7** is what rejects it: with no space anywhere, `–1-1` is a single cluster containing two dash runs, so the whole cluster is inert. Step 8 is load-bearing only for the _spaced_ form in case 24, where the range token's own spacing ends its cluster and step 7 cannot see the second dash |
763
+ | 27 | `en-US` | `a–1␣-␣1` | `a—1␣-␣1` | en-US's parenthetical is `em-tight` (not spaced), so **T1 never applies** — the token converts freely, length included. `de-DE`'s `en-spaced` parenthetical, by contrast, makes this token spaced, and T1 blocks it (the trailing `- 1` sits a far dash away): `run("a–1 - 1", deDE)` stays `a–1 - 1`, unconverted — this is what row 27 originally tested, and the mechanism is unchanged by step 2a's retirement |
764
+
765
+ ### Composition regression cases
766
+
767
+ Both families from [pipeline-idempotency.md](pipeline-idempotency.md) §4. Each rule was a fixed
768
+ point on its own output; the pipeline was not.
769
+
770
+ | # | Locale | Input | Output | Why |
771
+ | --- | ------- | ----------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
772
+ | 28 | `de-DE` | `.--.` | ⟶ | **family 1.** T2: the form is `en-spaced` and `cp[R]` is U+002E, from which `spaces` deletes a U+0020. Previously `. – .` then `. –.` |
773
+ | 29 | `ru` | `a--.` | ⟶ | same, `em-spaced` |
774
+ | 30 | `de-DE` | `(--a` | ⟶ | T2 on the left: `cp[L]` is U+0028, after which `spaces` deletes a U+0020 |
775
+ | 31 | `de-DE` | `a--)` | ⟶ | T2 on the right: `cp[R]` is U+0029 |
776
+ | 32 | `en-US` | `.--.` | `.—.` | `em-tight` emits no U+0020, so T2 never applies and there is nothing for `spaces` to undo |
777
+ | 33 | `fr` | `«⍽-⍽»` | ⟶ | **family 2.** Neither U+00A0 is spacing, so `lsp = rsp = 0`; the token is symmetric but `cp[L]` is the U+00A0, and **step 6** declines it as space-like. Previously the second pipeline pass produced `«⍽–⍽»` |
778
+ | 34 | `ru` | `слово⍽—␣столица` | ⟶ | `lsp = 0`, `rsp = 1` → the symmetry guard declines. The Russian `nbsp`-promoted form survives by being refused, not by being recomputed |
779
+ | 35 | `fr` | `«⍽–␣"` | ⟶ | **defect (d), §8.2.** The U+00A0 that `nbsp` N8 inserted after `«` is not spacing, so `lsp = 0` against `rsp = 1` and the symmetry guard declines. Under the previous left-only rule this read as symmetric and was promoted to `«⍽—␣"` on the second pass |
780
+ | 36 | `fr` | `«–␣"` | ⟶ | the pass-1 form of case 35: `cp[s-1]` is `«`, not a space, so the token is asymmetric from the start. `nbsp` then inserts the inner space, producing case 35, which is now a fixed point |
781
+ | 37 | `fr` | `«⍽–␣a` | ⟶ | the same family with a letter to the right; all five witnesses (`«`, `"`, `'`, `a`, `1`) behave identically |
782
+
783
+ ### Dash-glyph reclassification, spec 0.2.0 (formerly "the authored-dash guard — §3.2 step 2a")
784
+
785
+ The M4 gate rejected 1063 lines of exactly this shape, 30% of the whole diff: the author's spaced
786
+ em dashes rewritten to the `en-US` `em-tight` convention. `en-GB` was no escape — there the same
787
+ em dashes became en dashes on 1060 lines. Both English locales were restyling a deliberate
788
+ authorial choice across an entire corpus.
789
+
790
+ | # | Locale | Input | Output | Why |
791
+ | --- | ------- | -------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
792
+ | 38 | `en-US` | `documents␣—␣PDFs, invoices␣—␣and you` | `documents—PDFs, invoices—and you` | **the M4 blocker, reopened deliberately in spec 0.2.0** (§8.1). en-US's parenthetical is `em-tight`; the author's dash is already EM, so only its spacing is corrected — length happens to already match here |
793
+ | 39 | `en-GB` | `documents␣—␣PDFs␣—␣and you` | `documents – PDFs – and you` | the same input in the other English locale: en-GB's parenthetical is `en-spaced`, so the length converts (em → en) as well as the spacing already being correct — this is the M4 corpus's own shape, reopened on the same operator decision as row 38 |
794
+ | 40 | `en-US` | `The plan␣-␣if there is one␣-␣fails.` | `The plan—if there is one—fails.` | **hyphen input is unaffected** — this is the conversion the rule exists for, and it still fires |
795
+ | 41 | `de-DE` | `Der Plan—falls es einen gibt—fällt.` | `Der Plan – falls es einen gibt – fällt.` | an em dash typed by the author; de-DE's parenthetical is `en-spaced`, so both the length (em → en) and the spacing (tight → spaced) are corrected. Under 0.1.0's guard, and under the briefly-shipped 0.2.0 narrowing, this row stayed unchanged — retiring the guard outright is what converts it |
796
+ | 42 | `en-US` | `a-–b` | `a—b` | a **mixed run** of `DASH` glyphs is a single token, promoted exactly as the equivalent hyphen run (`a--b`) would be — P3 admits a `k = 2` run regardless of spacing |
797
+ | 43 | `en-US` | `1914–1918` | ⟶ | digit-flanked — never reaches `dashes` (§1, §3.3); already correctly a no-op under default options regardless. See [ranges.md](ranges.md) §6 row 43 for `ranges`' own behaviour (also a no-op here, since the token is already exactly `en-tight`) when explicitly enabled |
798
+ | 44 | `en-US` | `1914-1918` | ⟶ | digit-flanked — never reaches `dashes` (§1, §3.3); a byte-identical no-op with default options. See [ranges.md](ranges.md) §6 row 44 for what `ranges` does with this input when explicitly enabled (converts and binds) |
799
+
800
+ ### The joiner-transparency repair — §3.2b, dashes.md's current scope
801
+
802
+ Every row in the pre-0.5.0 version of this subsection was a digit-flanked token — `ranges`'
803
+ territory exclusively now. They are relocated to [ranges.md](ranges.md) §6 (rows 45-47), which is
804
+ the current normative source; row numbers are unchanged for citation stability. With default
805
+ options every one of them is a no-op here, for the same reason as rows 43-44 above: `dashes`
806
+ never reaches a digit-flanked token at all.
807
+
808
+ | # | Locale | Input | Output | Why |
809
+ | --- | ------- | ----------- | ------ | -------------------------------------------------------------------------------------------------------- |
810
+ | 45 | `de-DE` | `1-1␣-␣1` | ⟶ | digit-flanked — never reaches `dashes`; see [ranges.md](ranges.md) §6 row 45 (defect (e), §8.2, reproduced there under explicit opt-in) |
811
+ | 46 | `de-DE` | `1–1␣-␣1` | ⟶ | digit-flanked — never reaches `dashes`; see [ranges.md](ranges.md) §6 row 46 (the control for row 45) |
812
+ | 47 | `ru` | `1-1␣-␣1` | ⟶ | digit-flanked — never reaches `dashes`; see [ranges.md](ranges.md) §6 row 47 |
813
+
814
+ ### `el` — `parenthetical: "none"` (`range` is now [ranges.md](ranges.md)'s field, also `"none"`, not this rule's)
815
+
816
+ The first locale with `"none"` on **both** fields, so the rule is a **total no-op** for it in the
817
+ same provable sense `hyphen` is a no-op for a locale with empty lists. Tokens are still
818
+ classified — §3.3's "a range token is never reconsidered as a parenthetical" still holds — but no
819
+ classification has an emission to make. The rows below are therefore all "no change" rows by
820
+ construction, and they are worth pinning precisely because nothing else in the suite exercises
821
+ the both-`none` combination.
822
+
823
+ **The two fields are `"none"` for two different reasons, and collapsing them into one sentence
824
+ misrepresents the source.** An earlier revision of this section said the Greek source "is not
825
+ silent here, it is _ambivalent_". That is true of `range` and **false of `parenthetical`**, where
826
+ the guide is explicit.
827
+
828
+ - **`range: "none"` — genuine ambivalence.** The rule appears **twice** in §10.1.8 of the EU
829
+ Interinstitutional Style Guide (Greek edition), once under «Ενωτικό (-)» and once under «Παύλα
830
+ μεσαίου μεγέθους (–)», and **each occurrence explicitly concedes the other mark**. A source
831
+ that names both forms in both places is not being vague; it is declining to rank them.
832
+ Emitting either would express a preference the citation does not carry, so the rule emits
833
+ nothing.
834
+ - **`parenthetical: "none"` — a schema limit, not ambivalence.** The guide prescribes a U+2014
835
+ pair (its own header gives «Alt 0151») and states the spacing exactly: «Όπως η παρένθεση, η
836
+ διπλή παύλα δεν χωρίζεται με κενά διαστήματα από τη λέξη, φράση ή πρόταση που περικλείει·
837
+ αντίθετα, μπαίνουν διαστήματα πριν από την πρώτη και μετά τη δεύτερη παύλα.» — ordinary spaces
838
+ **outside** the pair, none on the **inner** edges. `dash.parenthetical`'s enum has no value for
839
+ that asymmetry: `"em-spaced"` puts a space on each side of _each_ dash, which is precisely what
840
+ the sentence forbids, and `"em-tight"` drops the outer spaces the sentence requires. So the
841
+ source is clear and the schema cannot record it. `"none"` is what the file must say until the
842
+ enum grows; the gap is with the operator and is **not** a licence to approximate.
843
+
844
+ In both cases `"none"` is the honest encoding, not a placeholder awaiting a default (§2) — but
845
+ only one of them is waiting on a _source_. `spec/locales/el.json`'s `dashes.note` carries the
846
+ detail, including a **source conflict** this document does not try to settle: the EU guide sets
847
+ U+2014 parenthetically while the state school grammar appears to set U+2013.
848
+
849
+ | # | Input | Output | Why |
850
+ | --- | -------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------- |
851
+ | 48 | `άτομα ηλικίας 25-45 ετών` | ⟶ | digit-flanked — never reaches `dashes` (§1, §3.3); see [ranges.md](ranges.md) §6 row 48 for `ranges`' own no-op here (`range: "none"` — nothing to emit even when explicitly enabled) |
852
+ | 49 | `Το σχέδιο␣-␣αν υπάρχει␣-␣αποτυγχάνει` | ⟶ | classified as a parenthetical, `parenthetical: "none"` → nothing emitted |
853
+ | 50 | `την περίοδο 1989–1991` | ⟶ | digit-flanked — never reaches `dashes` (§1, §3.3); already an en dash in the form the source prints, so [ranges.md](ranges.md) is inert here too even when explicitly enabled |
854
+
855
+ ### A locale with no verified convention — `parenthetical: "none"` (`range` is now [ranges.md](ranges.md)'s field, also `"none"`, not this rule's)
856
+
857
+ | # | Input | Output | Why |
858
+ | --- | ------------------------------------- | ------ | ------------------------------------------------------------------------------- |
859
+ | 51 | `The plan␣-␣if there is one␣-␣fails.` | ⟶ | the token is classified as parenthetical and then nothing is emitted |
860
+ | 52 | `1914-1918` | ⟶ | digit-flanked — never reaches `dashes` (§1, §3.3); see [ranges.md](ranges.md) §6 row 52 for `ranges`' own no-op here (locale has no verified `range` convention) |
861
+
862
+ Cases 3e, 3f, 3g, 3h, 3i, 4, 5, 6, 7, 8, 9, 10, 12, 15, 16, 17a, 17b, 18, 19, 20, 21, 24, 25, 26,
863
+ 28, 29, 30, 31, 33, 34, 35, 36, 37, 43, 46, 48, 49, 50, 51 and 52 are "no change" cases. (Rebuilt
864
+ programmatically from the rows themselves and re-checked against spec 0.2.0's live
865
+ `transform()` output. **17c, 27, 38, 39, 41 and 42 are no longer "no change" as of 0.2.0** and
866
+ were removed from this list — each converts visibly now that the authored-dash guard is
867
+ retired. 17a, 17b and 34 use guards unrelated to authorship — P4 and the symmetry guard — and
868
+ remain no-change exactly as before. Re-derive this list from the table, not from memory,
869
+ before trusting it: it has been wrong twice already in this document's history.)
870
+
871
+ ---
872
+
873
+ ## 7. Open questions
874
+
875
+ **Every item below is current** — an open question or a decided limitation of `dashes` itself, as
876
+ of spec 0.6.0. Range-specific open questions (ASCII-only digits, decimal ranges, abbreviated-year
877
+ ranges, scores, the U+2060 review hazard) moved to [ranges.md](ranges.md) §7, since `ranges` is
878
+ the rule they are now open *against*; purely historical material (the authored-dash guard's
879
+ development, the compound-label widening's original acceptance) moved to §8.
880
+
881
+ 1. **Named date ranges typed with an ASCII hyphen remain a genuine cross-rule miss — narrowed but
882
+ not closed by P5 (spec 0.6.0).** P5 only ever declines an *already-authored* U+2013; it has no
883
+ opinion about a hyphen. `1 May - 3 June` has a letter on one side of the hyphen, so `dashes`
884
+ (not `ranges`) still processes it and still produces an em dash in `en-US`; only
885
+ `1 May – 3 June` — where the author already typed U+2013 — is now protected. `dashes` owns the
886
+ remaining failure on its own side — it has no month-name data and P1/P4 give it no way to
887
+ recognise the hyphen-typed shape as anything but an ordinary parenthetical dash — and `ranges`
888
+ cannot help either, since `ranges` never sees a letter-flanked token at all (§1, §3.3).
889
+ Recognising the hyphen-typed case would need month-name lists, which is locale data the schema
890
+ does not have and which is not mine to add
891
+ unilaterally. Recorded as a known miss on `dashes`' side of the boundary.
892
+ 2. **P4 declines rather than converts, so `XV-XVII` keeps its hyphen** (§6 case 17b). The full fix
893
+ is to admit `ROMAN` runs as range-like candidates with guards of their own — but a
894
+ length-based guard does not transfer cleanly (`XV`/`XVII` differ in length) and a
895
+ non-decreasing check would need Roman-to-integer evaluation, a parser rather than a comparison.
896
+ Given all-caps English words built from Roman letters (`MIX`, `CIVIL`, `VIVID`), converting is
897
+ the direction that can damage, so v1 declines. If Russian fixtures show the miss matters, the
898
+ right shape is a length-bounded `ROMAN` run (say ≤ 7 code points) plus a real numeral
899
+ evaluation, specified here first.
900
+ 3. **A dash whose spacing is partly no-break is never normalised at all.** If an author writes
901
+ `mot⍽- autre` by hand, this rule declines rather than converting, because it cannot tell that
902
+ U+00A0 from one `nbsp` put there. That is a deliberate miss on the conservative side, and it is
903
+ the price of the structural discharge in §5.4. If real content shows it, the fix is **not** to
904
+ reinterpret no-break spacing here — that road has produced defects (§8.2) — but to have `nbsp`
905
+ avoid creating the shape, which requires it to know what a dash token is.
906
+ 4. **T1 (§3.2 step 8) costs a small class of legitimate normalisations**, and the cost is worth
907
+ naming rather than hiding. In a `-spaced` parenthetical locale, a tight dash that sits directly
908
+ against a digit run which has another dash beyond it is left alone: `de-DE` leaves
909
+ `Anhang A–1-2` untouched, where an unguarded rule would produce `Anhang A – 1-2`. I judge that
910
+ a _gain_ — `A–1`, `B-2`, `Teil C-3` are identifiers, and spacing them out is a false positive of
911
+ the kind the M4 gate exists to catch — but it is a behaviour change reached for a composition
912
+ reason (protecting `ranges`' next-pass verdict, §5.3), not a typographic one, and it should be
913
+ confirmed against real content. The guard fires only when a second dash is present near the
914
+ digit run, so the ordinary `Kapitel 3—Einleitung` still normalises.
915
+ 5. **Two of this rule's guards now exist mainly for composition with `ranges` rather than for
916
+ `dashes`' own typography** (step 7's cluster guard and step 8, T1). That is real machinery for
917
+ inputs — `a–1 - 1`, `a- - a` — that no author writes deliberately, kept because it protects a
918
+ *different* rule's next-pass verdict (§5.3) as much as it protects this rule's own. The
919
+ alternative design, which was not taken, is to declare a token inert whenever *any* other dash
920
+ lies within some fixed radius — one guard instead of two, but one that would also drop
921
+ `pp. 34-36 — see notes` and `— 1914-1918 годы`, both real Russian and English copy. Recorded so
922
+ the trade is visible if the guard set ever needs simplifying.
923
+ 6. **Decided refusals, verified against Lebedev's live service.** Recorded here so they are not
924
+ re-litigated:
925
+ - **A direct-speech dash at line start** — `—` opening a line of dialogue, and the conversion
926
+ of a leading `-` into one. Refused, and it is refused _by construction_ rather than by
927
+ preference: in `markdown` a leading `- ` is a list marker, so a rule that rewrote it would
928
+ silently destroy list structure in the author's own content format. §3.2 step 5 already
929
+ declines a token with no content to its left, and that is now a deliberate guarantee rather
930
+ than a side effect.
931
+ - **ISO date reformatting** (`2026-08-15` → any other form). Refused, and as of spec 0.5.0 this
932
+ is entirely [ranges.md](ranges.md)'s question to answer, not this rule's — `dashes` never
933
+ sees a digit-flanked token at all (§1, §3.3). Recorded here because the refusal predates the
934
+ split and the reasoning (declining the whole cluster rather than guessing) is the shared
935
+ philosophy both rules still apply via the cluster guard (§3.2 step 7).
936
+ - **`->` and `=>` as arrows.** Refused — they are source code far more often than prose, and
937
+ the symmetry guard already declines them.
938
+ - **Wrapping output in markup.** Refused as an architectural matter — see `symbols.md` §7.8.
939
+ Encoding a _guess_ about what a digit-dash chain means, rather than declining it outright, is
940
+ what makes an external service's markup-wrapping approach dangerous (it once wrapped a date
941
+ as a phone number); declining the whole cluster is the better trade.
942
+ 7. **T2's set is deliberately wider than `spaces`' `STRIP-BEFORE`, by U+2026, and no fixture
943
+ covers the difference.** §3.2 step 9 declines a `-spaced` form before U+2026 although `spaces`
944
+ would not delete a space there. Both readings are idempotent, so the property suite cannot
945
+ separate them — **only a fixture can**, and `a--…` in a `-spaced` locale has none. Needed: one
946
+ positive row per `-spaced` locale asserting that the input is returned unchanged. Until it
947
+ exists, a port could transcribe either set and stay green. (T2 is shared with `ranges`, §3.3
948
+ there — the same gap applies to a `-spaced` range replacement before U+2026.)
949
+
950
+ ---
951
+
952
+ ## 8. History
953
+
954
+ **This section is explicitly non-normative.** §1 through §7 above are the complete current
955
+ contract for `dashes`; nothing below is required of a conformant implementation. §8 exists so the
956
+ reasoning trail and defect record that produced that contract are not lost — several guards in
957
+ §3.2 and guarantees in §5 exist *because* of a specific historical counterexample, and an
958
+ implementer who understands why a guard exists is less likely to remove it by mistake. Every
959
+ subsection is dated and points to whichever current document — this one or
960
+ [ranges.md](ranges.md) — now normatively owns the behaviour it describes.
961
+
962
+ ### 8.1 The authored-dash guard: introduction and full retirement (spec 0.1.0 → 0.2.0)
963
+
964
+ Spec 0.1.0 introduced an unconditional guard at what is now §3.2 step 2a: any dash run containing
965
+ U+2013 or U+2014 was declined outright, glyph and spacing both, whatever the locale said. It was
966
+ itself a repair — an M4 corpus run found 1063 lines across 155 files where restyling an author's
967
+ dash (length and spacing changed together) was pure damage against a criterion (PLAN.md §8) that
968
+ names exactly that as a ship blocker.
969
+
970
+ A later revision inside 0.2.0 narrowed the guard rather than removing it — preserve the author's
971
+ dash length, correct only the spacing — after a `Минус — при разногласиях`-shaped operator
972
+ complaint showed the unconditional form leaving genuinely mis-spaced dashes broken forever. That
973
+ narrower design shipped only briefly: it rested on treating a dash's length as reliably an
974
+ authorial decision, and on review that premise did not hold in general — a dash's length in
975
+ ordinary prose is at least as often a copy-paste artefact or plain unfamiliarity with which mark
976
+ is which as it is deliberate, and this project has no way to tell the two apart from context.
977
+ **The guard was retired outright, on the same footing this rule has always used for spacing:
978
+ correcting an author's mechanical error is what a normalising pass is for, and an author who
979
+ wants their own typography untouched has always had the option of not running one.**
980
+
981
+ From spec 0.2.0 onward, U+2013/U+2014 carry no token-level special case anywhere in the algorithm:
982
+ a run holding them is classified and replaced exactly as the same run spelled with U+002D would be
983
+ (current: §3.1, §3.2 step 2a, §3.4 P2). Two consequences, current and worth restating plainly:
984
+
985
+ - **Every locale's dash convention now governs every dash in the document, hyphen-typed or not.**
986
+ `documents — PDFs, invoices — and you` and `documents–PDFs, invoices–and you` both become
987
+ `documents—PDFs, invoices—and you` in `en-US` (§6 case 38). This is the M4 corpus's 1063-line
988
+ class, reopened deliberately: the operator judged the M4 gate's finding reflected a false
989
+ premise about dash length being authorial, not a property the tool had to preserve regardless
990
+ of that premise.
991
+ - **A range already in its exact target form is still never bound; a range needing any visible
992
+ correction is** — current, [ranges.md](ranges.md) §3.3.1's invisible-edit test, not this rule's
993
+ concern at all as of spec 0.5.0 (§1). This asymmetry is unaffected by the guard's retirement —
994
+ it was never about authorship, only about reviewability (§8.6) — and remains the one most likely
995
+ to be reported as a bug (§6 cases 43, 44).
996
+
997
+ The length restriction could be reopened only by a locale field distinguishing "normalise dash
998
+ length" from "promote hyphens only" — a caller preference, not a typographic fact about a
999
+ language, and so not something that belongs in a locale file.
1000
+
1001
+ The section number `2a` (§3.2) is kept rather than renumbered, for the same reason a retired step
1002
+ generally keeps its number in this document: several documents cite it (this one's own §3.1,
1003
+ §3.4; `ranges.md` §3.1).
1004
+
1005
+ ### 8.2 Idempotency defects of the pre-0.5.0 combined rule
1006
+
1007
+ Through spec 0.4.x, this rule evaluated both the parenthetical and the digit-flanked (range)
1008
+ branches of every dash token, and five idempotency defects were found and closed during that
1009
+ period. Two remain live concerns for `dashes` alone today (§5.2 restates their guards directly,
1010
+ without walking through the defect history); three were purely an interaction between the two
1011
+ branches and are now `ranges`' own composition obligation ([ranges.md](ranges.md) §4).
1012
+
1013
+ **Defect (b) — `DASH SPACE DASH`, current guard: §3.2 step 6.** `en-US`, input `a- - a`. The first
1014
+ hyphen was asymmetric and rejected; the second, symmetric with `cp[L] = "-"`, was not rejected by
1015
+ the guard then in place, so it normalised to a tight em dash — placing it directly against the
1016
+ first hyphen, where the two formed a single run of length 2 with a different verdict on the next
1017
+ pass (`a- - a → a-—a → a—a`). **Repaired by extending §3.2 step 6** to reject a `DASH` neighbour at
1018
+ `cp[L]` or `cp[R]`, which is current §5.2's "`cp[L]`, `cp[R]`" argument and also what gives §3.5
1019
+ its non-overlap guarantee.
1020
+
1021
+ **Defect (d) — a manufactured left space, current guard: §3.2 step 3.** `fr`, where
1022
+ `quotes.primary.innerSpace` is `nbsp` and `dash.parenthetical` is `em-spaced`: `«– " → « – " → «
1023
+ — "`. Five witnesses, one family, `fr` only: `«` followed by U+002D or U+2013, then U+0020, then
1024
+ any content. On pass 1 the token was asymmetric (`«` is not a space) and declined; `nbsp` then
1025
+ inserted the guillemet's inner U+00A0; on pass 2, under the *previous* rule ("a no-break space
1026
+ counts as spacing on the left"), the token read as symmetric and was wrongly promoted from en to
1027
+ em. This mattered more than (a)–(c) below: the input was *already-typeset text* built from
1028
+ characters the pipeline itself emits (`«` from `quotes`, U+00A0 from `nbsp`, the dash from this
1029
+ rule), reachable by re-processing the pipeline's own output — the CMS-on-every-save case
1030
+ PLAN.md §1 names as the reason idempotency is the package's headline property. It was also
1031
+ self-inflicted: the left-only rule was introduced as the repair for an earlier `nbsp`-composition
1032
+ defect, and was precisely what made this one reachable. **Repaired by removing that whole class of
1033
+ reasoning** (current §3.2 step 3): only U+0020 is this rule's spacing, a no-break space on either
1034
+ side makes the token inert, and every character `nbsp` can emit is therefore inert here — the
1035
+ structural CO-S discharge current §5.4 states.
1036
+
1037
+ **Defect (a) — `DASH digits DASH`, historical: now `ranges`' concern.** `ru`, input `a—0–0`. The
1038
+ em dash was a parenthetical token that normalised to `␣—␣`; the en dash was a range candidate
1039
+ whose `before` was that em dash (G2 rejected it); after the first edit, `before` became a space,
1040
+ G2 no longer rejected, and the second pass emitted `0—0` — not a fixed point. Repaired by the
1041
+ cluster guard (§3.2 step 7, shared): `a—0–0` is one cluster with two dash runs, so both tokens are
1042
+ inert from the start. The cluster guard remains current and shared (§5.2), but the *reason* it
1043
+ mattered here — a range token's G2 reading a neighbour's dash — is [ranges.md](ranges.md) §4's own
1044
+ composition-obligation proof now, not this document's.
1045
+
1046
+ **Defect (c) — the spaced sibling of (a), historical: now `ranges`' concern.** `de-DE`/`ru`, input
1047
+ `a–1 - 1`: the cluster guard from defect (a) was not enough, because a *spaced* range token's own
1048
+ outer spacing ends its cluster before it reaches the neighbouring dash. Witness: `a–1 - 1 → a – 1
1049
+ - 1 → a – 1–1`, reproduced in every locale whose parenthetical is `-spaced` and whose range is not
1050
+ `none`. Repaired by the spacing-transition guard (T1, §3.2 step 8): a tight token may not become
1051
+ spaced when doing so would insert a space between itself and a digit run that has another dash on
1052
+ its far side. T1 is current and lives in `dashes` (current §5.3) precisely because it is
1053
+ `dashes`' own edit that must not disturb `ranges`' verdict on the next pass — the guard itself is
1054
+ current, but the G2-side half of the story (why this is the only verdict input `dashes` can
1055
+ perturb) is `ranges`' concern; the full historical argument is §8.3.
1056
+
1057
+ **Defect (e) — the joiner blinds a neighbour's G2, historical: now `ranges`' concern.** Every
1058
+ locale whose range is not `none`, witness `1-1 - 1 → 1⟨J⟩–⟨J⟩1 - 1 → 1⟨J⟩–⟨J⟩1⟨J⟩–⟨J⟩1` (seven
1059
+ characters — beyond the length-4 exhaustive bound normative at the time, which is why the shipped
1060
+ suite did not catch it). §3.3.1's joiner sat between a digit run and a dash, so on the next pass
1061
+ the neighbouring token's `before` read as a `JOINER`, in neither `DASH` nor `INERT-DASH`, and G2
1062
+ wrongly admitted a range that should still have been rejected. Repaired by §3.2b: every guard that
1063
+ leaves its own token reads an *effective neighbour* that skips joiners, so `before`/`after` see
1064
+ through the joiner to the real dash underneath. §3.2b is current and shared; the G2-specific
1065
+ consequence is [ranges.md](ranges.md)'s own concern now.
1066
+
1067
+ **Summary of fixes**, for citation stability (§6 cases 18–21, 24–26 pin these):
1068
+
1069
+ 1. Defect (b): extending step 6 to reject a `DASH` neighbour.
1070
+ 2. Defect (a): the cluster guard, step 7.
1071
+ 3. Defect (c): the spacing-transition guard, step 8.
1072
+ 4. Defect (d): removing "no-break space counts as spacing" entirely, step 3.
1073
+ 5. Defect (e): joiner-transparent effective neighbours, §3.2b.
1074
+
1075
+ ### 8.3 The pre-0.5.0 range-token verdict argument (G2 as the one perturbable input)
1076
+
1077
+ Reproduced as history — this is the fullest statement of *why* T1 was the only guard defects (c)
1078
+ and (e) needed, from when `before`/`after`/G1-G5 were still this document's own concern rather
1079
+ than `ranges`':
1080
+
1081
+ For a range token `T`, the verdict inputs `Lrun`, `Rrun` (digit runs) are untouched by any edit —
1082
+ no edit touches a digit. `before`/`after` are the code points adjacent to `Lrun`/`Rrun`, and for a
1083
+ *spaced* token they lie two positions beyond `cp[L]`/`cp[R]`, outside the token's own cluster,
1084
+ where neither the isolation guard (step 6) nor the cluster guard (step 7) can see them. G1, G3 and
1085
+ G5 read them for membership in `LETTER`, `.`/`,`/`/` and digit value — none of which any edit can
1086
+ produce or destroy. **G2 reads them for membership in `DASH` ∪ `INERT-DASH`, and that is the one
1087
+ verdict input the rule was capable of changing**, via a `-spaced` replacement: a neighbouring
1088
+ tight token that becomes spaced replaces a dash at that position with a space, turning a G2
1089
+ rejection into a G2 admission.
1090
+
1091
+ Every instance of that flip has the same shape. Let `T` be a range token G2 rejected, so `before`
1092
+ (say) is a dash at index `d-1`, where `d` is the start of `Lrun`. For that dash to become a space,
1093
+ it must be inside some edited token `A`'s span, and `A`'s replacement must place a U+0020 at
1094
+ exactly that index — which requires `A`'s run to end at `d-1` with `rsp_A = 0`, and `A`'s chosen
1095
+ form to be `-spaced`. In other words: `A` is tight, `A` is about to become spaced, and the side of
1096
+ `A` about to gain a space faces `Lrun`, which has a dash on its far side. That is precisely, and
1097
+ only, the configuration T1 (§3.2 step 8) declares inert. The mirrored argument gives the `after`
1098
+ side. T1's two-code-point reach is exactly what the case requires and no more: `T`'s dash sits at
1099
+ distance 1 from `Lrun` if `T` is tight and distance 2 if `T` is spaced, and a `-spaced` replacement
1100
+ inserts exactly one U+0020, so no other distance is reachable. The reverse flip — `before` going
1101
+ from a space to a dash — is benign and needs no guard: it turns a G2 admission into a G2
1102
+ rejection, and a rejection emits nothing, so a token converted on an earlier pass simply keeps the
1103
+ form it was given.
1104
+
1105
+ The current version of this argument, for the tokens `ranges` now owns exclusively, is
1106
+ [ranges.md](ranges.md) §4.
1107
+
1108
+ ### 8.4 Range binding and CO-S under the retired authored-dash guard (spec 0.2.0)
1109
+
1110
+ Reproduced as history: at the time this was written, a tight range was bound (§3.3.1, pre-0.5.0)
1111
+ by this same rule, and the CO-S argument below is what proved that binding survived the authored
1112
+ dash guard's retirement. A tight range this rule bound was, on its next pass, a single U+2013 or
1113
+ U+2014 reached by crossing its own `JOINER` pair. Under 0.1.0's guard this was the case the guard
1114
+ was built to recognise (via the crossed-joiner check re-admitting a digit-flanked token); under
1115
+ 0.2.0 the same token was just an ordinary range candidate that happened to already hold the
1116
+ correct glyph, and §3.3.1's (pre-0.5.0) *invisible-edit test* — compute the unbound form, and bind
1117
+ only if the unbound form would itself be a visible change — is what kept it a fixed point: the
1118
+ unbound form equalled the input exactly (right glyph, right spacing), so `bind` stayed `false` and
1119
+ the joiners were re-affirmed rather than stripped. This was confirmed, not merely argued, at the
1120
+ time: the then-combined rule's exhaustive bounded sweep and its `fast-check` idempotency property
1121
+ both covered the 0.2.0 code path and were green.
1122
+
1123
+ The current version of this proof, for `ranges`' own binding, is [ranges.md](ranges.md) §4's
1124
+ rule-order argument together with `tests/rules/ranges.test.ts`.
1125
+
1126
+ ### 8.5 Compound labels and the G4 `(1,2)` branch: why the widening was accepted (spec ≤ 0.4.1)
1127
+
1128
+ `Figure 5-10`, `Table 3-12` and `Section 2-14` mean "chapter 5, figure 10" — a two-part label, not
1129
+ a range — and, when the rule that owned range detection also owned an equal-length-or-`(1,2)`
1130
+ guard, the hyphen converted to an en dash. So did the proper nouns `9-11` and `7-11`. The exposure
1131
+ was pre-existing, not introduced by the `(1,2)` branch: `Figure 3-7` is `(1,1)`, satisfies the
1132
+ equal-length branch, and had converted since the first version of the rule. What the `(1,2)`
1133
+ branch did was *widen* an exposure that already shipped — from `Figure 3-7` to `Figure 5-10` as
1134
+ well. That distinction is why the widening was judged acceptable at the time: declining `5-10`
1135
+ bought no protection against the class that was actually exposed, while costing a construction
1136
+ (`takes 5-10 days`, `aged 9-10`, `0-60`) about as common as ranges get in English.
1137
+
1138
+ Separating the two needs the preceding noun — `Figure`, `Table`, `Section`, `Fig.`, `Abb.`,
1139
+ `рис.` — an open-ended, per-locale, per-house-style word list with no natural closure and no
1140
+ citable evidence at the time (or since). The failure mode was judged mild relative to the
1141
+ phone-number case: a hyphen becoming an en dash inside a label is a normalisation a reader may not
1142
+ even notice, whereas converting `555-1234` would corrupt a number.
1143
+
1144
+ **Current status.** As of spec 0.5.0, this entire question belongs to `ranges` (order 25, off by
1145
+ default) — see [ranges.md](ranges.md) §1 and §5 for the current statement of the same tradeoff,
1146
+ which is unchanged in substance from what is reproduced above, only in which document and which
1147
+ rule's default-on status governs its reachability. `dashes.md` §6 row 3j shows the current
1148
+ byte-identical no-op with default options; `ranges.md` §6 row 3j shows what `ranges` still does
1149
+ with the same input when explicitly enabled.
1150
+
1151
+ ### 8.6 The U+2060 range binding: a review hazard, and what it cost (spec 0.3.x)
1152
+
1153
+ Every other change this rule made, before the split, was catchable by eye — a hyphen becomes a
1154
+ dash, a space appears or disappears. §3.3.1's joiner was zero-width: the bound form
1155
+ `1914⟨J⟩–⟨J⟩1918` and the unbound `1914–1918` were pixel-identical in every font, and a unified
1156
+ diff showed a changed line with no visible difference on it. Two consequences, both paid for once:
1157
+
1158
+ - A worked-example table row that omitted the joiner looked correct on screen and was wrong at
1159
+ the byte level — several rows stayed stale after §3.3.1 landed, caught only by a fixture author
1160
+ checking raw bytes rather than trusting the rendering.
1161
+ - The change was spec-first and legitimate, and still broke 32 fixture cases in another agent's
1162
+ files without announcing itself, because a normative change whose effect cannot be seen needs
1163
+ to be *told*, not shown.
1164
+
1165
+ The joiner also cost one idempotency defect — defect (e), §8.2 — needing a seven-character witness
1166
+ to find, beyond the length-4 exhaustive bound normative at the time. Neither cost is an argument
1167
+ against the binding, which is well-sourced, correct, and what stops a range breaking across lines
1168
+ (the alternative, markup, is forbidden by `modes.md` §4). It is a record of what a zero-width
1169
+ emission costs in reviewability, kept so the next person weighing one can read it rather than
1170
+ re-derive it.
1171
+
1172
+ **Current status.** Range binding is exclusively [ranges.md](ranges.md)'s behaviour now (§3.3.1
1173
+ there); the still-open, general lesson — that a future zero-width emission needs its own
1174
+ announcement, and that fixture rows containing one must be checked in the escaped mirror, never
1175
+ read on screen — is restated as a current open question at [ranges.md](ranges.md) §7.
1176
+
1177
+ ### 8.7 Retired locale-field notes (spec 0.5.0)
1178
+
1179
+ `dash.range` carries the same five-value enum as `dash.parenthetical`, including `em-*` (Russian
1180
+ uses it) and `none` — this was already true before the split and needed no schema change to move
1181
+ which rule reads the field (§2). §3.6's old preservation clause (a no-break space beside a dash
1182
+ was to be carried across an edit rather than replaced) is gone: a no-break space beside a dash now
1183
+ makes the token inert (§3.2 step 3 + step 6), so no edit can contain one and there is nothing to
1184
+ preserve — the Russian case it was written for is covered by declining instead (§6 case 34).
1185
+
1186
+ ### 8.8 P5, the authored en-dash mark-identity veto: fresh M4 evidence and why it does not reopen
1187
+ §8.1's retired guards (spec 0.6.0)
1188
+
1189
+ A fresh M4 dogfooding pass against the author's own blog corpus, run against the spec 0.5.0
1190
+ implementation candidate, surfaced 8 human-rejected review rows, every one an authored U+2013 that this rule's P2 promoted
1191
+ to U+2014: a cost range (`$0.005–$0.018`), a price-placeholder range (`€X–€Y`), a date range
1192
+ occurring three times (`18 Oct–2 Nov`, tight and spaced), a compound article-title label
1193
+ (`Senior–Junior Gap`), and both dashes of a joint eponymous algorithm name (`Fowler–Noll–Vo`).
1194
+ Independently computed against the same corpus: of the 8598 rows in that review, **exactly 8**
1195
+ touch an authored U+2013 as their edit's source character, and all 8 were the rejects above —
1196
+ **zero** of the corpus's other 8589 accepted edits involve an authored en-dash at all. In this
1197
+ real corpus, every single instance of "author typed U+2013" was range/joint-name damage, and
1198
+ correcting an authored en-dash's glyph never once helped.
1199
+
1200
+ **Why this does not reopen 0.1.0's guard or 0.2.0's narrower one (§8.1).** Both of those declined
1201
+ *any* dash whose length "looked off," including a hyphen-typed compound word's edge case, an
1202
+ authored em-dash that merely needed re-spacing, and a run mixing glyphs — a broad claim the
1203
+ operator judged, on the 1063-line M4 evidence available then, to be wrong more often than right
1204
+ ("a dash's length in ordinary prose is at least as often a copy-paste artefact... as it is
1205
+ deliberate"). P5 makes a categorically narrower claim: not about dash *length* in general, but
1206
+ about one specific, already-established mark — U+2013, used **alone**, as a pure single-code-point
1207
+ run — which is not merely "a differently-sized em dash" the way `--` or `---` are, but a distinct
1208
+ conventional glyph with its own established roles (range, connection, joint-naming) that a
1209
+ length-based or "any DASH glyph" argument does not reach. §8.1's own reasoning for retiring the
1210
+ guard — treating length as unreliable authorial signal — does not transfer to treating *which of
1211
+ two established marks the author reached for* as unreliable, and the fresh evidence above is
1212
+ specific to exactly that distinction: not "any authored dash," but "authored U+2013 alone."
1213
+ `a-–b` (a mixed run) and every input holding U+2014 are unaffected by P5 and continue exactly as
1214
+ 0.2.0 left them (§6 case 42, case 38) — P5 does not touch em-dash handling at all, and this
1215
+ corpus's evidence gives no reason to: it found zero em-dash rejects.
1216
+
1217
+ **Why P5 is unconditional across every locale, not keyed to `dash.parenthetical`'s target
1218
+ glyph.** An earlier draft of this guard declined only when the locale's own target glyph was not
1219
+ U+2013 (reasoning: if the target *is* U+2013, only spacing changes, never the glyph, so nothing
1220
+ mark-identity-relevant happens). That is true of *this rule's own single-token replacement*, but
1221
+ it does not follow that a target-glyph-conditioned guard is safe: the failure P5 exists to
1222
+ prevent is an authored en-dash range or joint name being silently re-spaced into ordinary
1223
+ sentence punctuation, and that risk is exactly as real when the target glyph is U+2013
1224
+ (`en-GB`/`de-DE`/`de-CH`/`fi`/`sv`, all `en-spaced`) as when it is U+2014 — a tight authored
1225
+ `18 Oct–2 Nov` respaced to `18 Oct – 2 Nov` in an en-spaced locale reads exactly as much like
1226
+ ordinary parenthetical punctuation as the em-dash case does; only the glyph substitution is
1227
+ absent, not the risk. P5 therefore declines the whole token — glyph *and* spacing both — in every
1228
+ locale, on the same footing P4's Roman-numeral veto declines regardless of what
1229
+ `dash.parenthetical` says: a veto that depended on the locale's own target would leave the
1230
+ identical semantic failure reachable in exactly the locales whose target happens to already be
1231
+ U+2013, which is precisely backwards from what fresh evidence about *this* glyph is meant to
1232
+ protect.
1233
+
1234
+ **Fixture cost, computed, not estimated.** Every locale's `dashes` fixtures were checked for a
1235
+ row exercising "authored en-dash promoted to a different glyph": none exists anywhere in the
1236
+ canonical set. The only prose claim P5 falsifies is `en-us-dashes-authored-em-dash-respaced`'s
1237
+ note (§6), which asserted an authored en-dash converts identically to an authored em-dash; that
1238
+ note is corrected in the same change that introduces P5.