@spintax/core 0.7.0 → 0.8.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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,367 @@
3
3
  All notable changes to `@spintax/core` are documented here. This project adheres to
4
4
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
5
 
6
+ ## 0.8.0 — 2026-09-13
7
+
8
+ Two defects found by the ports while they mirrored 0.7.0, both places where this engine read a
9
+ template differently from the PHP engines — and both measured on those engines before anything
10
+ changed: the character classes of the post-process, and the key that decides which constructs are
11
+ re-read as text (#80).
12
+
13
+ **The post-process reads characters the way PHP does.** PHP compiles every pattern of the cosmetic
14
+ stage with `/u`, and `/u` turns on PCRE2_UCP: `\s`, `\d`, `\w` and `\b` are Unicode classes there.
15
+ This engine took them for ASCII — the belief was written into its own comments ("no PCRE_UCP") — and
16
+ the three ports that copied the classes did the same. Measured on both PHP engines:
17
+
18
+ | input | 0.7.0 | both PHP engines, and now this one |
19
+ |---|---|---|
20
+ | `и т.д. и т.п. всё` | `И т. Д. И т. П. Всё` | `И т.д. и т.п. всё` |
21
+ | `открой пример.рф сегодня` | `Открой пример. Рф сегодня` | `Открой пример.рф сегодня` |
22
+ | `пишите на info@сайт.рф` | `Пишите на info@сайт. Рф` | `Пишите на info@сайт.рф` |
23
+ | `end.<NBSP>next` | `End. <NBSP>next` | `End.<NBSP>Next` |
24
+ | `в 5<NBSP>тыс.<NBSP>руб.` | `В 5<NBSP>тыс. <NBSP>руб.` | `В 5<NBSP>тыс.<NBSP>руб.` |
25
+
26
+ **A conditional or a config reference inside `{…}`/`[…]` is text before the split, too (#80).** 0.7.0
27
+ re-read a construct as text when a `%var%` sat directly in it, and decided that from a key narrower
28
+ than its own rule:
29
+
30
+ | template | 0.7.0 | both PHP engines, and now this one |
31
+ |---|---|---|
32
+ | `[<minsize=%n%;maxsize=%n%>a\|b\|c]`, `n=1` | all three elements | one element |
33
+ | `[<sep=%S%>a\|b]`, `S=", "` | `b a` | `b, a` |
34
+ | `[{?f?a\|b\|x}\|c]` | `c b\|x` — a raw pipe | three elements |
35
+ | `[<sep=", ";lastsep=" and ">{?f?live casino}\|slots\|poker]` | `slots, poker and ` | `poker and slots` |
36
+ | `[<sep=", ">slots\|{live casino\|}\|poker]`, the empty option picked | `slots, , poker` | `slots, poker` |
37
+
38
+ Minor rather than patch: rendered text changes for every template of these shapes, and
39
+ `AST_VERSION` moves to 4.
40
+
41
+ ### Fixed
42
+
43
+ - **The post-process classes are PHP's UCP classes** (`internal/charclass.ts`). Whitespace is PCRE2's
44
+ `\s` — NBSP, the thin spaces, NEL — and not JavaScript's, which adds U+FEFF and misses U+0085 and
45
+ U+180E. `\b` counts a letter of any script as a word character. The digit in the two spacing
46
+ lookaheads is any `\p{Nd}`. The decimal shield stays ASCII: PHP writes that one pattern without `/u`.
47
+ - **Conditional truthiness uses the same whitespace.** A value of U+FEFF alone is truthy (it was blank
48
+ here); a value of U+0085 alone is blank (it was truthy).
49
+ - **The permutation-config patterns go the other way.** PHP writes them without `/u`, so their `\s` is
50
+ ASCII, while JavaScript's `\s` is Unicode on any flags. A no-break space around `=` is no longer
51
+ config whitespace — `[<minsize<NBSP>=<NBSP>1>a|b|c]` is one separator, as in PHP — and the validator
52
+ agrees: `[<foo<NBSP>=1>a|b]` is valid, `[<minsize=2<NBSP>>a|b]` is `permutation.minsize-not-integer`.
53
+ - **A reference anywhere in a permutation's `<config>` marks it for the re-read** (#80). 0.7.0 tested
54
+ the PARSED `sep` and `lastsep`, where a size reference never arrives (a size that is not digits
55
+ parses to nothing) and neither does an unquoted separator (it parses to the default). The raw
56
+ header is tested now, so `minsize=%n%`, `maxsize=%n%` and `sep=%S%` take their values from the
57
+ context, as they always have in PHP. So does a whole `{?…}` there or in a per-element separator —
58
+ `[<lastsep="{?en? and | и }">a|b|c]` printed the raw conditional unless a neighbour happened to
59
+ trigger the re-read (found in review). A bare `{?` does not mark: the first cut of that check made
60
+ every level of `[<{?}>a|[<{?}>a|…]]` rescan its body for a conditional that was not there, twice
61
+ 0.7.0's time on deep nesting (found by the Codex gate).
62
+ - **A `{?…}` conditional directly in `{…}`/`[…]` marks the construct** (#80), whatever its branches
63
+ hold — 0.7.0 marked it only when a `%var%` sat in a branch. The plugin resolves conditionals at
64
+ Stage 6a, before any bracket is read, so a taken branch's `|` separates options, an empty branch
65
+ leaves an empty permutation element that is dropped, and whitespace at a branch's edge is the
66
+ element's edge. 0.7.0's notes kept the empty element on purpose as a pathological divergence; a
67
+ list item gated by a flag is an ordinary template, and in one it rendered `Есть покер, слоты и.`.
68
+ - **A permutation element is its rendered text, trimmed, and one that renders empty is dropped** (#80).
69
+ The plugin resolves every nested enumeration before it splits a permutation, so an optional item
70
+ written `{live casino|}` that picks its empty option leaves `slots||poker` there — two elements —
71
+ while this engine kept a blank third and printed `slots, , poker`; `[<minsize=3;maxsize=3>a|{b|}|c]`
72
+ counted three and printed `a c`. The conditional fix above did not reach it: the re-read drops a
73
+ part that is empty as TEXT, and `{b|}` is not empty until it is rendered. Found by the differential
74
+ below after the conditional half was in; the size pick and the shuffle count the elements that
75
+ remain, and an element that is neither empty nor padded draws as before.
76
+ - **`AST_VERSION` 3 → 4.** The node shape is 3's, but an `Ast` cached by 0.7.0 lacks `raw` exactly
77
+ where the wider key puts it and would render the old output; the guard turns that into
78
+ `AstVersionError`. Nothing persists a handle across versions.
79
+ - **Three patterns no longer scan a long run once per character** (found in review). Trimming each
80
+ assembled element with `/[…]+$/` retried from every position of a whitespace run inside the text —
81
+ the first cut of the element fix made `[a%v%y|z]` over 51 000 spaces take 3 s, and 13 s from a
82
+ 362-byte macro template; the PHP trims are loops now, in the parser as well, where the same regex
83
+ already cost seconds on a re-read. And two post-process patterns that were quadratic IN 0.7.0 got
84
+ new triggers from the wider whitespace class: removing whitespace before punctuation rescanned a run
85
+ with no punctuation after it (100 000 form feeds: 10 s in 0.7.0; a macro of NBSP or U+3000: 10–31 s
86
+ on the first cut), and capitalizing after a line break rescanned a run of breaks and spaces with no
87
+ letter after it (20 000 of `\n` plus a space: 4 s in 0.7.0). Each now starts a match only where the
88
+ run starts — the same matches, checked over 500 000 generated strings — and all of these take
89
+ milliseconds.
90
+ - **`parse()` and `render()` no longer throw on a long tag-shaped permutation config.** Whether
91
+ `[<name>…]` opens with an HTML tag was decided by compiling `</name\s*>` from the name itself, and V8
92
+ will not compile that past about 7.8 KB of name: `SyntaxError: Invalid regular expression … Stack
93
+ overflow`, straight out of `parse()`. A 7.8 KB source was enough — under the reference Worker's 8 KB
94
+ cap — and so were 333 bytes of `#set` doubling a letter inside a re-read config, which spell the name
95
+ at render time. §9.2 says the engine never throws on content; 0.7.0 threw on both.
96
+ The closing tag is found by a scan now, matching exactly what the pattern matched: any case, and the
97
+ two non-ASCII letters `iu` folds into ASCII (U+017F, U+212A). Found by the scaling probe behind the
98
+ note on template scans below.
99
+ - **`constructor` and `__proto__` are ordinary variable names.** The variable and definition maps were
100
+ plain JavaScript objects, so both names found `Object`'s own members, and assigning `__proto__` stored
101
+ nothing. `render('a %constructor% b')` threw `TypeError` — §9.2 lets `render()` throw only on a resolver
102
+ or a foreign `Ast` — while `validate()` called the name merely undefined, so nothing could screen it out.
103
+ `%__proto__%` rendered as nothing, `{?constructor?…}` and `{?__proto__?…}` took the then-branch, a
104
+ `#set`, a `#def` or a context value under `__proto__` was lost, a runtime list under it spliced
105
+ `[object Object]`, and the validator withheld `plural.arity`, `plural.count-macro` and
106
+ `plural.nested-brackets` for a `#set` of either name — valid where both PHP validators say invalid.
107
+ Both PHP engines read variables from arrays and always treated them as ordinary names. The maps have
108
+ no prototype now (`internal/name-map.ts`). Found by the Codex gate on the nesting rewrite, where
109
+ `%__proto__%` had left the expansion budget `NaN`.
110
+
111
+ ### Changed, visibly
112
+
113
+ - A no-break space behaves like a space around punctuation: removed before `,.;:!?`, no second space
114
+ inserted after one, and the next sentence capitalized through it. French typography with U+202F
115
+ before `!` or `?` loses it — as a plain `bonjour !` always has, in every engine.
116
+ - A glued Cyrillic sentence stays glued: `конец.Начало` matches the bare-domain shield in PHP and now
117
+ here, exactly as `compact.Game` always has in ASCII. Whether the shield should take a capitalized
118
+ TLD at all is #79.
119
+
120
+ ### Corpus
121
+
122
+ Twenty fixtures, every expectation taken from BOTH PHP engines (docker, real code): fifteen in
123
+ `render-postprocess.json` — Cyrillic multi-dot abbreviations, an IDN domain, email and TLD, NBSP before
124
+ and after punctuation and after a whitelisted abbreviation, a URL that ends at NBSP, an Arabic-Indic
125
+ digit, NEL as whitespace and U+FEFF as not, a glued Cyrillic sentence, `_` before an abbreviation — plus
126
+ `conditional/bom-only-is-truthy`, `conditional/nel-only-is-falsy`,
127
+ `perm/config-nbsp-is-not-config-whitespace` and its two `validate/*` twins. Nineteen fail on 0.7.0; the
128
+ twentieth is a negative guard. No earlier fixture put a non-ASCII character next to a boundary or a
129
+ whitespace class — every domain, email and multi-dot case was Latin — which is how a premise stated in
130
+ a comment stayed green for two months. `postprocess.test.ts` had pinned the mangled `т.д.` "in both
131
+ engines", with an assertion that passed on the capital letter alone.
132
+
133
+ Seven more `splice/*` fixtures for #80, again from both PHP engines: a size and an unquoted separator
134
+ taken from a variable, a taken branch carrying a pipe in `[…]` and in `{…}`, a flag-gated list item,
135
+ a branch trimmed at an element's edge — six fail on 0.7.0 — and the negative
136
+ `splice/conditional-without-pipes-keeps-draws`, which pins that a conditional changing nothing
137
+ structural draws exactly where 0.7.0 drew. Three `perm/*` fixtures for an element that renders empty or
138
+ padded: `perm/nested-empty-option-drops-element`, `perm/nested-option-edge-whitespace-trimmed` and
139
+ `perm/dropped-element-narrows-the-size-range`, all failing on 0.7.0. Four from the review, all failing on
140
+ 0.7.0: `splice/conditional-in-per-element-separator`, `splice/conditional-in-config-lastsep`, and the
141
+ separator of a dropped element — `perm/dropped-element-takes-its-separator` (`[a<1>|{x|}|b]` is `a b`)
142
+ and `perm/dropped-element-passes-on-its-trailing-separator` (`[a<1>|{x|}<2>|b]` is `a2b`).
143
+
144
+ **The corpus can pin how many** (#74). `validate` cases take an optional `diagnosticCount` — the exact
145
+ number of diagnostics per code — where the subset match used to be the only assertion, and that
146
+ subset match is what passed two million circular-reference diagnostics for eleven days (#59). Two
147
+ cases carry it: `validate/cycle-diamond-terminates` (22, one per name) and the new
148
+ `validate/plural-count-macro-per-reference`, which records #73's decision: `plural.count-macro` once
149
+ per tainted reference in the count slot, as this engine, `spintax-core` and both PHP validators already
150
+ emit it. No engine output changed for either.
151
+
152
+ Twelve fixtures for the prototype names, every expectation taken from both PHP engines: eight in
153
+ `render-semantics.json` — undefined references, conditionals, a `#set` of each name, context keys, a
154
+ `#def` rolled once, a `#def` naming them undefined, a runtime list spliced into a permutation, a form
155
+ slot — and four in `validate.json`: the three withheld plural verdicts and the negative
156
+ `validate/prototype-names-defined-and-undefined`. Eleven fail on 0.7.0. A JSON-decoded context carries
157
+ `__proto__` as an ordinary key in every engine's harness; a JavaScript object literal would not.
158
+
159
+ ### Notes
160
+
161
+ **Cost.** Against 0.7.0, the post-process of this whole batch is faster on prose and on HTML blocks and
162
+ about a third slower on shield-heavy text — V8 runs a Unicode-class lookbehind more slowly than its ASCII
163
+ `\b`, and every shield pattern carries one now. Per 1 000 calls, the four builds side by side:
164
+
165
+ | input | 0.7.0 | the classes alone (regexes) | first linear scanners | now |
166
+ |---|---|---|---|---|
167
+ | prose, 857 characters | 34 ms | 32 ms | 108 ms | 19 ms |
168
+ | HTML blocks, about 700 characters | 33 ms | 31 ms | 86 ms | 27 ms |
169
+ | a URL, email, domain, decimal and abbreviation line, 237 characters | 36 ms | 46 ms | 70 ms | 49 ms |
170
+ | the same line ×3 200, 756 KB, once | 113 ms | 151 ms | 223 ms | 158 ms |
171
+
172
+ **The post-process is linear — and was a live denial of service in every release before this.** Eight
173
+ of its passes were global regex replaces that a long run made retry from every start inside it: the
174
+ email shield (one long word), the bare-domain shield (a dotted chain in any script), the
175
+ trailing-punctuation cut of a URL, the space after a run of sentence marks, and the three capitalizers
176
+ after a sentence end, a block tag and a line break (an unclosed `<`, a run of `<p>`). None of those
177
+ runs needs a long template: 336 bytes of `#set` doubling one letter expand to 131 000 characters, whose
178
+ post-process took 25 s, and the dotted, mark and tag units ran longer — so any host rendering untrusted
179
+ templates with post-process on, the reference Worker and the hosted MCP server among them, could be held
180
+ for as long as its CPU limit allowed. The expansion budget bounded the size of that text, never the time
181
+ to post-process it. One more stage was quadratic on its own terms (found by the Codex gate): the restore
182
+ of a text that carries U+0000, where #54 keeps the reference loop's reading — one `split`/`join` per
183
+ placeholder — so a few hundred bytes doubling a NUL and a decimal took 2.1 s at 16 000 decimals, four
184
+ times that per doubling.
185
+
186
+ The shields and the capitalizers are scanners now. They run the plugin's own patterns at the same starts,
187
+ in the same order, and skip only starts that provably fail. Every start inside one run of email-local
188
+ characters meets the same `@` or none, so the email shield works back from each `@` — a text without one
189
+ costs nothing. An attempt that fails at the start of a chain of labels fails at every later start in that
190
+ chain, because the chain's own labels prefix any match further in, so the domain shield is one regex that
191
+ matches at the starts it tries and consumes the chain when there is no domain — and is skipped when no dot
192
+ in the text is followed by a label's first character. A lead has exactly one tokenization — a tag ends at
193
+ the first `>` — so the capitalizers find their boundaries natively, walk a short lead, and build an index of
194
+ where each lead ends, from the right, only when a lead holds a tag or runs past 32 characters; one index
195
+ serves all three passes unless a capital grew the text (`ß` → `SS`). The URL cut is a loop, and the
196
+ sentence-mark space starts a match only where its run starts. Output is byte-identical: 7.9 million strings
197
+ against the regex implementation — exhaustive over small alphabets aimed at each pass, plus random soup — and
198
+ 5.8 million more aimed at the final form of each scanner, with every real control mutation caught, and the
199
+ 20 000-input post-process differential against both PHP engines unchanged at zero. Those macro templates now
200
+ render half a megabyte to a megabyte in 0.1–0.2 s.
201
+
202
+ The first cut of these scanners paid for linearity on every call: a regex call per word, a JavaScript step
203
+ per character, three times — 3.4 times the regex passes on a short paragraph (the table above). The scaling
204
+ bench, one large text, could not show that, and this note first said the bench was no slower on prose. The
205
+ shapes above are what came after measuring a thousand short renders instead.
206
+
207
+ The NUL-path restore computes the loop's result in one pass. An occurrence of a key is always a stretch
208
+ of the original text between two `\x00`s — no stored value holds a `\x00`, and no whole stored value
209
+ fits inside a key name — so the only way one replacement touches another is by taking a delimiter they
210
+ share, and visiting the candidates in the loop's key order, each only while both its delimiters survive,
211
+ is the loop. 1.9 million NUL-carrying strings come out identical, three control mutations were caught,
212
+ and the 16 000-decimal case takes 130 ms.
213
+
214
+ **The template scans are linear too — and part of that was live the same way.** They had the
215
+ post-process's flaw. The parser matched each `{` and `[` by counting forward to its closer, which
216
+ for an opener that never closes is the end of the text, from every such opener; the plural scan did the
217
+ same for `{plural …}`, and the validator ran `/\[<([^>]*?)>/` from every `[<`. Lazy or overlapping
218
+ patterns restarted from every character of a run in four more places: a `/# … #/` comment that never
219
+ closes, a `#set`/`#def` value with a long whitespace run inside, a tag-shaped config holding a quoted
220
+ `>`, and the validator's config-key test on a long word. The re-read of a construct hands the parser
221
+ whatever its macros spell, so the parser's half was reachable from a few hundred bytes:
222
+
223
+ | input (Node 22) | 0.7.0 | now |
224
+ |---|---|---|
225
+ | 333–341 bytes of `#set` spelling `[<`, `{?a?`, `[<li>`, `[<sep="` or `{plural 1:` inside `{…}` — render | 1.3–6.2 s, ×4 per doubling | 63–117 ms |
226
+ | 387 bytes spelling form feeds into a tag-shaped config that holds a quoted `>` — render | 8.6 s, ×4 per doubling | 117 ms |
227
+ | `[<` repeated, 32 KB — render / validate | 1.4 s / 0.27 s | 29 ms / 23 ms |
228
+ | `{plural 1:` repeated, 32 KB — render / validate | 0.32 s / 0.20 s | 41 ms / 17 ms |
229
+ | `/#a` repeated, 32 KB — parse / validate | 0.14 s / 0.14 s | 10 ms / 12 ms |
230
+ | a `#set` value holding 32 KB of spaces — parse / validate | 2.8 s / 11.8 s | 4 ms / 14 ms |
231
+ | `[<a`, 32 KB of spaces, a quoted `>` — parse | 2.0 s | 10 ms |
232
+ | `[<` and a 32 KB word `>` — validate | 0.9 s | 10 ms |
233
+
234
+ Brackets are paired once for the whole text, and the plural scan and the renderer's conditional pass use
235
+ the same tables — the deep-nesting note below says how a construct is read now. The comment strip and the
236
+ config scan are `indexOf` loops. The directive value ends at its last non-blank character instead of
237
+ growing lazily, the tag pattern takes one whitespace character where two runs used to trade them, and a
238
+ config key starts only where a word does — each matching exactly what it matched before. Output is
239
+ byte-identical to the code before: 4.3 million generated strings through `parse`, `render` (three rng
240
+ strategies, with and without post-process), `validate`, `analyze`, `extract` and the plural scan —
241
+ exhaustive over alphabets aimed at each rewritten scan, plus random soup — with all twelve control
242
+ mutations caught first.
243
+
244
+ **Deep nesting no longer costs its depth again at every level — and the re-read had made that a live denial
245
+ of service.** #68 kept nesting super-linear on the ground that depth costs source: at the hosted 8 KB cap,
246
+ 4 000 levels answered in about a second. 0.7.0's re-read took that ground away, because a construct re-read
247
+ as text hands the parser whatever its macros spell (found by the Codex gate):
248
+
249
+ | input (Node 22, the two measured side by side) | 0.7.0 | now |
250
+ |---|---|---|
251
+ | 705 bytes: `{%o15%x%c15%\|y}` over fifteen `#set` doublings of `{` and of `}` — render | 40 s | 0.14 s |
252
+ | a `#def` rolling those 32 768 levels, referenced once — render | 40 s | 0.28 s |
253
+ | `{a…x…b}` 16 384 levels deep, every level adding text — render | 19 s | 0.14 s |
254
+ | `[a\|…x…]` 16 384 levels deep — render | 20 s | 0.24 s |
255
+ | `{a%u%…x…}` 8 192 levels, each marked for the re-read by an undefined reference — render | 20 s | 0.10 s |
256
+ | 20 000 nested conditionals naming a `#def` of 2^17 form feeds — render | 13 s | 0.30 s |
257
+ | `{` 100 000 levels deep — parse | > 90 s | 0.21 s |
258
+
259
+ Four costs were paid once per level, and each is gone:
260
+
261
+ - **The parser read every construct's content in full** — to find its closer, to split it on top-level
262
+ pipes, to find a conditional's pipe, a config's end, a closing tag, a trailing separator — and handed its
263
+ children copies. A construct's children are spans of one text now, and every one of those questions is
264
+ answered for a span from tables built once over the text (`internal/text-index.ts`): bracket pairs; the
265
+ top-level pipes grouped by the brace and bracket totals in front of them, since `split_top_level`'s two
266
+ signed counters split exactly where both totals equal the ones at the span's start; a conditional's pipe
267
+ by jumping each opener to where one stack of both bracket kinds closes it, which is what the clamped
268
+ counter counts; the quote parity of every `>`; every closing tag by name; every `<` and `>`. Most of those
269
+ answers are a binary search in a sorted list of positions, so a template of n characters parses in
270
+ O(n log n) at worst rather than O(n) — the logarithm shows on many small siblings: 200 000 `{a|b}` parse in
271
+ 150 ms against 0.7.0's 137, 100 000 `[<sep=",">a|b]` in 208 against 161 (found by the Codex gate).
272
+ - **The walk joined each frame's output into a new string**, copying a construct's text once for every
273
+ level above it. Frames hand fragments up instead — joins, and windows for a trimmed permutation element,
274
+ every join knowing how many PHP trim characters sit at its ends — and the text is made once.
275
+ - **A construct marked for the re-read ran both conditional passes and the fixpoint over its body to find
276
+ nothing to change**, once per marked level. The index answers first: the body can change only if it holds
277
+ a conditional the pass would resolve — a well-formed head whose brace closes inside it — or a reference
278
+ expansion would substitute, a name the variable map defines while the budget lasts. When neither, the
279
+ splice returns what it always returned. The conditional pass finds its heads once, too, instead of
280
+ searching the text again from the start of every span.
281
+ - **Truthiness scanned a value's leading whitespace at every conditional**; it is computed once per
282
+ variable map.
283
+
284
+ Output is byte-identical to the code before this change: the 4.3 million alphabet strings above again, and
285
+ 20 000 generated documents — long outputs, padded and empty elements, constructs marked for the re-read
286
+ with defined, undefined and budget-starved references, conditionals in bodies, count slots and `#def`
287
+ rolls, nests up to 300 deep — through eight probes each (`parse`, `validate`, `analyze`, four rng
288
+ strategies with and without post-process, and a pre-parsed `Ast`), with the control mutations caught first:
289
+ a join that stops counting trim characters after its first piece, one that never counts them at its end, a
290
+ trim window a character late, a re-read check that forgets conditionals or inverted heads or compares names
291
+ case-sensitively, one truthiness cache for every map, a conditional pass that skips a head at a span's start,
292
+ top-level pipes grouped by one depth, a flipped quote parity, a closing tag on the span's end counted
293
+ outside it. The PHP engines are no linear reference here: both resolve innermost-first with a
294
+ full-text pass per level and stop at 10 000 levels with an exception — in `spintax/core`, 8 192 nested
295
+ permutations took 5.9 s and 32 768 nested enumerations threw. Real templates did not pay for any of this —
296
+ the render cost note below has the numbers. An `Ast` copied out of the process (it is not a serialization
297
+ format) loses the side table the index lives in, and renders the same text by reading bodies as before.
298
+
299
+ **Two costs bounded by the source, recorded rather than changed.** The same gate found `validate()`
300
+ formatting a large definition cycle in quadratic time — each name walks the cycle to count what its
301
+ printed route leaves out: 8 000 names 6.5 s, the 320 that fit in 8 KB 23 ms — and `render()` ordering
302
+ `#def`s in quadratic-to-cubic time: 1 600 in a chain 2 s, 400 in 90 ms. A macro cannot spell either,
303
+ both were so in 0.7.0, and the hosted surfaces cap a source at 8 KB.
304
+
305
+ **Two PHP builds differ at the margin.** PCRE2 10.43 made non-spacing marks and connector punctuation
306
+ word characters, so PHP 8.3 sees a boundary between `x` and U+0301 that PHP 8.4 does not. The corpus
307
+ runs PHP 8.4 and this engine follows it; recorded in the conformance README.
308
+
309
+ **What the wider re-read does not move.** A construct re-read because it holds a conditional draws
310
+ exactly as the tree did whenever the taken branch changes nothing structural — measured on 0.7.0
311
+ before the change, 500 seeds over five shapes (a permutation with separators, one with a size range,
312
+ an enumeration, a nested permutation in a branch, an else branch): identical renders. What changes is
313
+ the three kinds of text the plugin always produced here — a pipe in a branch, an empty element, a
314
+ trimmed edge — and only in the renders where one occurs: a permutation whose elements all render
315
+ non-empty and unpadded picks and shuffles exactly as before. `validate()` still reports `minsize=%n%`
316
+ as `permutation.minsize-not-integer`, as both PHP validators do: a verdict about the template as
317
+ written, unchanged.
318
+
319
+ **Verified against PHP, not only against 0.7.0.** A generator of construct-heavy templates —
320
+ conditionals with empty, piped and padded branches, references in configs and elements, macros
321
+ carrying conditionals, per-element separators — 3 000 renders with `rng: first` and `last` through
322
+ both PHP engines: 0.7.0 differed on 416, this engine on 0. The first cut of #80 still differed on
323
+ 147, every one an element that rendered empty through a nested construct; that is how the trim-and-drop
324
+ above was found. And 20 000 generated post-process inputs (Latin, Cyrillic, marks, every space the two
325
+ dialects disagree on, shield triggers): 0.7.0 differed on 6 229, this engine on none but the recorded
326
+ final `trim`, which accounts for all 3 689 of its edge-only differences. A zero holds for what those
327
+ generators can build, and is stated here as that.
328
+
329
+ **On a production host's templates.** 2 522 template-and-flag combinations taken from a live
330
+ deployment's migrations, 15 132 renders against 0.7.0: 30 change, and `validate`/`analyze`/`extract`
331
+ change on none. They are the two defects, in real copy: Russian brand articles stop printing
332
+ `Т. Е. Каждый перевод…` and `…х5, х7 и т. Д.`, and a payment FAQ whose list gates one item behind
333
+ `{?CasinoHasCrypto?…|}` stops printing `…are offered.. Traditional methods…` for a casino without
334
+ crypto — the blank element and its `. ` separator, which post-process had turned into a double stop.
335
+ The same generated documents without any of the changed shapes — 600 of them, 3 600 renders plus
336
+ `validate`/`analyze`/`extract` — are byte-identical, and the harness caught all three deliberate control
337
+ mutations first.
338
+
339
+ **Render cost.** A construct re-read because it holds a conditional is parsed again on every render, so
340
+ the payment FAQ whose list gates one item behind a flag is the one real template that got slower: 1 000
341
+ renders take 120 ms against 0.7.0's 86. Everything else measured got faster once the index answered most
342
+ re-reads without reading, the walk stopped copying and the post-process lost its per-call overhead: the FAQ
343
+ with a providers gate four times over, 513 ms against 1 322; plain prose, 145 against 198; all 1 262
344
+ construct-bearing literals of the production host's migrations, rendered once each, 178 ms against 262.
345
+
346
+ **The prototype names, verified against a renamed oracle.** 0.7.0's output under those names was the
347
+ defect, so the fixed build is compared with the build before it on each document with `__proto__` and
348
+ `constructor` renamed to ordinary names of the same length, renamed back in the output: 20 000 generated
349
+ documents, 19 875 carrying one of the names in a reference, a conditional, a `#set`, a `#def`, a plural
350
+ slot or a context key, through `parse`, `validate`, `analyze` and five renders — no difference, with three
351
+ control mutations caught first (an unrelated truthiness change, and the alias map or the `#def` map left a
352
+ plain object). The 449 009 alphabet strings, which name neither, are unchanged. The maps are merged by a
353
+ loop: `Object.assign` into a map with no prototype took five times the spread it replaced, once per
354
+ `#def` — 1 000 renders of a template with 60 context variables and 20 definitions went from 920 ms to
355
+ 1 336 — and the loop brings that to 968. The production templates, the FAQ and prose measure as before.
356
+
357
+ **Recorded, not closed** — in the conformance README, under the known divergences. A value carrying an
358
+ unbalanced bracket (`[a|{%L%}]` with `L = "x}|y"`) re-cuts the enclosing construct in PHP and only its
359
+ own here. Four shapes of the same family, where a nested pick leaves markup the permutation's reader
360
+ sees in PHP and a tree parsed before the pick: a leading element that renders empty exposes its
361
+ `<…>` as the permutation's config, a pick ending in `<…>` becomes a per-element separator, a
362
+ construct inside the config is resolved first, and a nested permutation joined by `|` re-splits the
363
+ outer one (`[[<|>a|b]|c]`). All four were already so in 0.7.0. A generated differential aimed at exactly these meets
364
+ the first twice in 3 000 renders. And the Unicode tables: Node 22's are 17.0, PCRE2 10.44's are 15.0,
365
+ so a character assigned since then can sit on the other side of a boundary.
366
+
6
367
  ## 0.7.0 — 2026-09-12
7
368
 
8
369
  **A `%variable%` written directly inside `{…}` or `[…]` is now spliced as TEXT before the construct
package/README.md CHANGED
@@ -81,10 +81,10 @@ dedupe in the host and cap the retries: a template may simply not have N combina
81
81
  | --- | --- | --- |
82
82
  | Enumeration | `{a\|b\|c}` | pick one (nestable: `{a\|{b\|c}}`) |
83
83
  | Permutation | `[a\|b\|c]` | pick N, shuffle, join — `[<minsize=1;maxsize=2;sep=", ">a\|b\|c]` |
84
- | Variable | `%var%` | substitute a context value |
84
+ | Variable | `%var%` | substitute a context value — inside `{…}`/`[…]` the value is spliced as text first, so a `|` it carries separates options |
85
85
  | Local set | `#set %v% = value` | define a macro — re-picked at every use |
86
86
  | Local def | `#def %v% = value` | define a value — picked once per render, held at every use |
87
- | Conditional | `{?VAR?then\|else}` | `then` if `VAR` is truthy, else `else` |
87
+ | Conditional | `{?VAR?then\|else}` | `then` if `VAR` is truthy, else `else` — inside `{…}`/`[…]` it resolves first, so a `\|` in the taken branch separates options and an empty one drops its element |
88
88
  | Plural | `{plural %n%: one\|few\|many}` | grammatical agreement by locale |
89
89
  | Include | `#include "slug-or-id"` | embed another template (host-resolved) |
90
90
  | Comment | `/# … #/` | stripped before rendering |
@@ -154,6 +154,11 @@ render('%bio%', { context: { bio: neutralize('Save {50|60}% today') }, postProce
154
154
  // → "Save {50|60}% today" (the braces stay literal, not a random pick)
155
155
  ```
156
156
 
157
+ The pipe is deliberately **not** shielded: it means nothing outside a construct, and inside one it
158
+ is the separator. A neutralized value an author places in `{…}`/`[…]` is still split on its `|`, in
159
+ this engine as in every other in the family. Keep such a value out of a construct if it must stay
160
+ one option.
161
+
157
162
  ## Using it without writing code
158
163
 
159
164
  - **n8n** — install [`n8n-nodes-spintax`](https://www.npmjs.com/package/n8n-nodes-spintax)