@spintax/core 0.6.1 → 0.7.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,108 @@
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.7.0 — 2026-09-12
7
+
8
+ **A `%variable%` written directly inside `{…}` or `[…]` is now spliced as TEXT before the construct
9
+ is split** — so a pipe-joined value is a list of options or elements, as it has always been in the
10
+ PHP engines. Reported from production: a brand preset
11
+ `[<minsize=5;maxsize=7;sep=", ";lastsep=" and ">%CasinoProvidersList%]` over a 57-name runtime
12
+ list rendered all 57 names joined with `|` — no size pick, no shuffle, no separators — in 131
13
+ published rows across 15 tenants. Minor rather than patch: rendered text changes for every template
14
+ of that shape, and `AST_VERSION` moves.
15
+
16
+ ### Fixed
17
+
18
+ - **The defect.** The tree is built before any value exists, so `[<…>%list%]` was ONE element
19
+ holding a variable node; `resolveVariable` handed a construct-free value back as finished text,
20
+ and the `|` that separates elements in every PHP engine — whose `expand_variables` runs over the
21
+ whole text before any bracket is read — was never seen. Same for `{%list%}`, for `[a|%list%|b]`,
22
+ and for a `#set` or `#def` wrapping the construct.
23
+ - **The rule, restated for a tree walk.** The parser keeps a construct's raw body when it holds a
24
+ *direct* reference — at the top level of an option, inside a conditional's branches (the plugin
25
+ resolves `{?…}` before it expands, Stage 6a), or in a separator string (`<sep="%S%">`,
26
+ `a <%S%> |`). At render time such a construct is re-read from its expanded text in the plugin's
27
+ own order: conditionals → expansion → conditionals → parse. Every other construct keeps the tree
28
+ it was parsed into, and with it the RNG order the corpus pins; a spliced value with no structural
29
+ characters re-reads to the same tree, so those renders are unchanged too.
30
+ - **The hop budget inside a bracket equals the one outside.** The textual fixpoint runs the
31
+ plugin's own `<= MAX_VARIABLE_DEPTH` — 51 passes, minus the hops a macro re-parse already spent —
32
+ and whatever it leaves is frozen for the whole subtree, so the plugin's 51 hops hold in every
33
+ shape: the mutual cycle leaves `%b%`, `#set %b% = x%b%y` leaves 51 pairs, a 51-deep chain into
34
+ `x|y` reaches the body as text and is split — inside `{…}` as at top level (#57's pins, moved
35
+ inside). **The plural slots now use the same arithmetic.** They ran a flat 50 passes and rendered
36
+ the picked form unfrozen, so a 51-deep alias chain in the count slot erased a block the plugin
37
+ renders, and a 52-deep chain in a form resolved to its end where the plugin leaves `%a52%`.
38
+ Pre-existing, found by the review gate, closed here.
39
+
40
+ ### Changed
41
+
42
+ - `AST_VERSION` 2 → 3: `EnumerationNode` and `PermutationNode` gained `raw`. A handle from an
43
+ older parser would render the old, wrong output, so the version guard turns it into
44
+ `AstVersionError` — the same reason `#def` bumped it. Nothing persists a handle across versions.
45
+ - **Every substitution now charges the expansion budget (#69), as it does in PHP.**
46
+ `resolveVariable` used to charge only construct-bearing values — harmless while a plain value
47
+ could only be a leaf, and the one door left open once a re-read construct could hand it
48
+ references its own fixpoint had cut off: 2^12 references to a 1 KiB value produced 4 MiB out of
49
+ a 1 MiB allowance, and 2^20 is an out-of-memory abort. Found by the review gate before release.
50
+ A template referencing a 65 KB plain list sixteen times now reaches the budget, as it already
51
+ did in PHP; the references past it stay literal.
52
+ - **The truncated shape of an expansion bomb is half what it was.** The substitution at the depth
53
+ cap is charged like every other now (it was free), so the 62-character bomb renders 0.57 MB where it
54
+ rendered 1.14 MB. Not parity-gated — the conformance README says what a truncated explosion looks
55
+ like is engine-specific — and the contract holds unchanged: terminates, never throws, bounded output,
56
+ unaffordable references left literal. The hosted MCP server's output cap now stops that bomb at
57
+ variant 4 rather than 2 (its test moved with it).
58
+ - `neutralize()` docs: the pipe is deliberately not shielded. A neutralized value the author places
59
+ inside `{…}`/`[…]` is split on its `|` in every engine — this one was immune only by the defect.
60
+
61
+ ### Corpus
62
+
63
+ Nineteen `splice/*` fixtures in `render-semantics.json`, expected outputs taken from BOTH PHP
64
+ engines (docker, real code): the inline permutation, the `#set` wrapper over a runtime list,
65
+ literals around the variable, `{%L%}`, a conditional branch carrying the list, a `#def` wrapper
66
+ (rolled once, then spliced), `<sep="%S%">`, `lastsep="%S%"` and a per-element `<%S%>`, a macro
67
+ value with brackets AND a pipe, three hop-budget pins (a 50-alias chain into `x|y` split on the 51st
68
+ hop inside `{…}`, the same chain reaching its number in a plural count slot, a 51-alias chain left
69
+ literal in a form), the production
70
+ preset shape — and three negatives: a top-level reference is NOT split, an undefined name stays one
71
+ literal element, a nested construct splits at its own level only. No prior fixture had a variable
72
+ inside a construct, which is how this hid in four tree-walk engines (TS, Python, Object Pascal,
73
+ .NET) while the PHP engines were right all along. The siblings fail the new cases until they mirror
74
+ the rule.
75
+
76
+ ### Notes
77
+
78
+ **What stays as it was, on purpose.** Two pre-existing tree-walk divergences in the *non-triggered*
79
+ path are unchanged: an element that renders to empty (a nested conditional yielding `''`) is kept
80
+ here and dropped by PHP, and PHP's top-level scan splits the fullwidth plural fallback on its pipes.
81
+ Both pathological, neither reported. A *triggered* construct drops the empty element as PHP does,
82
+ because the text is what PHP sees — and trims a conditional's taken branch at an element's edge,
83
+ as the plugin trims after Stage 6a. The fullwidth fallback is the exception in BOTH paths: the plugin
84
+ resolves plurals before it reads a bracket, so a fallback's ASCII pipes reach the enclosing construct
85
+ there, while here the re-read makes the plural a node again and it renders whole. Only a template
86
+ `validate()` already rejects (`plural.arity`, `plural.nested-brackets`) can reach it; recorded in the
87
+ conformance README under the known divergences rather than closed (review finding, kept open on
88
+ purpose).
89
+
90
+ **The hop budget, corrected in review.** The first cut gave the fixpoint 50 passes and rendered the
91
+ leftover at the depth cap, which spliced it once more as finished text: a 52nd hop when reached through
92
+ a macro, and one that hid a structural terminal value from the split (a 51-deep chain into `x|y`
93
+ inside `{…}` rendered `x|y`, where the plugin's 51st pass yields `{x|y}` and picks). Now the fixpoint
94
+ runs the plugin's own `<= MAX_VARIABLE_DEPTH` — 51 passes, minus the hops a macro re-parse already
95
+ spent — and whatever is left is frozen for the whole subtree.
96
+
97
+ **Verified.** The 0.6.1 build against this one over generated documents (seeded LCG, every
98
+ construct, `render` in both post-process modes, `analyze`, `extract`) — the harness first shown to
99
+ catch two deliberate control mutations. Documents with no direct reference anywhere: byte-identical,
100
+ and `analyze`/`extract` identical on every document. Documents whose constructs DO carry a direct
101
+ reference, with plain values and no conditional: byte-identical over 1 788 renders and three seeds —
102
+ that is the claim that a spliced value with no structural characters re-reads to the same tree. With
103
+ conditionals and construct-bearing definition values allowed, 159 of 922 triggered renders differ, in
104
+ exactly the three ways the PHP text has always differed from the old tree: whitespace only (a taken
105
+ branch trimmed at an element's edge), an element that became empty dropped before the shuffle (so
106
+ every later draw shifts), or a `|` carried in by a rolled `#def`.
107
+
6
108
  ## 0.6.1 — 2026-08-19
7
109
 
8
110
  **The engine no longer throws on deeply nested content** (issue #68). `parse()`, `render()` and
package/README.md CHANGED
@@ -14,11 +14,12 @@ parse, render, validate, extract, analyze, and neutralize spintax templates.
14
14
 
15
15
  - **Zero runtime dependencies.** Runs unchanged on Cloudflare Workers, Node 18+, and in the browser.
16
16
  - **ESM-first, dual CJS.** Ships `.d.ts` types for both.
17
- - **One syntax contract, [five independent engines](https://spintax.net/spintax-engines/).**
17
+ - **One syntax contract, [six independent engines](https://spintax.net/spintax-engines/).**
18
18
  This package, [`spintax/core`](https://packagist.org/packages/spintax/core) (PHP),
19
- [`spintax-core`](https://pypi.org/project/spintax-core/) (Python), an Object Pascal engine, and
19
+ [`spintax-core`](https://pypi.org/project/spintax-core/) (Python), an Object Pascal engine,
20
+ [`Spintax.Core`](https://www.nuget.org/packages/Spintax.Core/) (.NET), and
20
21
  the [Spintax WordPress plugin](https://wordpress.org/plugins/spintax/) are all held to the same
21
- behavior contract by a **shared golden corpus of 234 fixtures** — deterministic verdicts, plural
22
+ behavior contract by a **shared golden corpus of 258 fixtures** — deterministic verdicts, plural
22
23
  buckets, conditionals, `#set`/`#def` semantics and post-processing agree everywhere, so a
23
24
  template is an asset you can move between runtimes, not a lock-in.
24
25
  - **MIT** licensed.
package/dist/index.cjs CHANGED
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  // src/internal/ast.ts
4
- var AST_VERSION = 2;
4
+ var AST_VERSION = 3;
5
5
  function walk(nodes, visit) {
6
6
  const stack = [{ list: nodes, i: 0 }];
7
7
  while (stack.length > 0) {
@@ -187,19 +187,31 @@ function planBraceConstruct(content) {
187
187
  }
188
188
  return {
189
189
  texts: splitTopLevel(content),
190
- build: (children) => ({ type: "enumeration", options: children })
190
+ build: (children) => hasDirectReference(children) ? { type: "enumeration", options: children, raw: content } : { type: "enumeration", options: children }
191
191
  };
192
192
  }
193
+ function hasDirectReference(lists) {
194
+ const stack = [...lists];
195
+ while (stack.length > 0) {
196
+ const list = stack.pop();
197
+ for (const node of list) {
198
+ if (node.type === "variable") return true;
199
+ if (node.type === "conditional") stack.push(node.then, node.else);
200
+ }
201
+ }
202
+ return false;
203
+ }
204
+ var REFERENCE_RE = /%\w+%/u;
193
205
  function planPermutation(rawInner) {
194
206
  const { config, content } = extractPermutationConfig(rawInner);
195
207
  const { texts, separators } = permutationElements(splitTopLevel(content));
208
+ const separatorHasRef = REFERENCE_RE.test(config.sep) || config.lastsep !== null && REFERENCE_RE.test(config.lastsep) || separators.some((sep) => sep !== null && REFERENCE_RE.test(sep));
196
209
  return {
197
210
  texts,
198
- build: (children) => ({
199
- type: "permutation",
200
- config,
201
- options: children.map((nodes, i) => ({ nodes, separator: separators[i] ?? null }))
202
- })
211
+ build: (children) => {
212
+ const options = children.map((nodes, i) => ({ nodes, separator: separators[i] ?? null }));
213
+ return separatorHasRef || hasDirectReference(children) ? { type: "permutation", config, options, raw: rawInner } : { type: "permutation", config, options };
214
+ }
203
215
  };
204
216
  }
205
217
  function recognizeConditional(text, contentStart, contentEnd) {
@@ -1088,7 +1100,7 @@ function renderNode(node, opts) {
1088
1100
  case "variable":
1089
1101
  return resolveVariable(node.name, opts);
1090
1102
  case "enumeration":
1091
- return renderEnumeration(node.options, opts);
1103
+ return renderEnumeration(node, opts);
1092
1104
  case "permutation":
1093
1105
  return renderPermutation(node, opts);
1094
1106
  case "conditional":
@@ -1102,15 +1114,20 @@ function randomInt(rng, min, max) {
1102
1114
  }
1103
1115
  function resolveVariable(name, opts) {
1104
1116
  const value = opts.vars[name.toLowerCase()];
1105
- if (value === void 0) return `%${name}%`;
1106
- if (opts.depth >= MAX_VARIABLE_DEPTH || !/[{[%]/u.test(value)) return value;
1117
+ if (value === void 0 || opts.frozen === true) return `%${name}%`;
1107
1118
  if (opts.budget.left <= 0) return `%${name}%`;
1108
1119
  opts.budget.left -= value.length;
1120
+ if (opts.depth >= MAX_VARIABLE_DEPTH || !/[{[%]/u.test(value)) return value;
1109
1121
  return renderNodes(parseSequence(value), { ...opts, depth: opts.depth + 1 });
1110
1122
  }
1111
- function expandVarsOnly(text, opts) {
1123
+ function passesLeft(opts) {
1124
+ return Math.max(1, MAX_VARIABLE_DEPTH - opts.depth + 1);
1125
+ }
1126
+ function expandVarsFixpoint(text, opts, passes) {
1127
+ if (opts.frozen === true) return { text, changed: false, converged: true };
1112
1128
  let out = text;
1113
- for (let i = 0; i < MAX_VARIABLE_DEPTH; i += 1) {
1129
+ let changedAny = false;
1130
+ for (let i = 0; i < passes; i += 1) {
1114
1131
  let changed = false;
1115
1132
  out = out.replace(/%(\w+)%/gu, (m, name) => {
1116
1133
  const value = opts.vars[name.toLowerCase()];
@@ -1120,9 +1137,10 @@ function expandVarsOnly(text, opts) {
1120
1137
  changed = true;
1121
1138
  return value;
1122
1139
  });
1123
- if (!changed) break;
1140
+ if (!changed) return { text: out, changed: changedAny, converged: true };
1141
+ changedAny = true;
1124
1142
  }
1125
- return out;
1143
+ return { text: out, changed: changedAny, converged: false };
1126
1144
  }
1127
1145
  function conditionalTakesThen(name, inverted, opts) {
1128
1146
  const value = opts.vars[name.toLowerCase()];
@@ -1133,7 +1151,7 @@ function renderConditional(node, opts) {
1133
1151
  const truthy = conditionalTakesThen(node.name, node.inverted, opts);
1134
1152
  return { lists: [truthy ? node.then : node.else], done: [], assemble: (parts) => parts[0] ?? "" };
1135
1153
  }
1136
- function resolveCountConditionals(text, opts) {
1154
+ function resolveConditionalsInText(text, opts) {
1137
1155
  if (!text.includes("{?")) return text;
1138
1156
  const close = matchBraces(text);
1139
1157
  const out = [];
@@ -1180,8 +1198,11 @@ function matchBraces(text) {
1180
1198
  return close;
1181
1199
  }
1182
1200
  function renderPlural(node, opts) {
1183
- const countRaw = resolveCountConditionals(expandVarsOnly(node.countRaw, opts), opts);
1184
- const formsRaw = expandVarsOnly(node.formsRaw, opts);
1201
+ const passes = passesLeft(opts);
1202
+ const countPass = expandVarsFixpoint(node.countRaw, opts, passes);
1203
+ const formsPass = expandVarsFixpoint(node.formsRaw, opts, passes);
1204
+ const countRaw = resolveConditionalsInText(countPass.text, opts);
1205
+ const formsRaw = formsPass.text;
1185
1206
  const base = normalizeBaseLang(opts.locale);
1186
1207
  const report = (issue) => opts.onPluralError?.(issue);
1187
1208
  if (/[{}[\]]/u.test(formsRaw)) {
@@ -1216,6 +1237,7 @@ function renderPlural(node, opts) {
1216
1237
  return fullwidthVerbatim(countRaw, formsRaw);
1217
1238
  }
1218
1239
  const picked = pluralFor(base, Number.parseInt(count, 10), forms);
1240
+ if (!formsPass.converged) return renderNodes(parseSequence(picked), { ...opts, frozen: true });
1219
1241
  return { lists: [parseSequence(picked)], done: [], assemble: (parts) => parts[0] ?? "" };
1220
1242
  }
1221
1243
  function rawConstruct(countRaw, formsRaw) {
@@ -1224,13 +1246,27 @@ function rawConstruct(countRaw, formsRaw) {
1224
1246
  function fullwidthVerbatim(countRaw, formsRaw) {
1225
1247
  return rawConstruct(countRaw, formsRaw).replace(/\{/gu, "\uFF5B").replace(/\}/gu, "\uFF5D");
1226
1248
  }
1227
- function renderEnumeration(options, opts) {
1249
+ function renderEnumeration(node, opts) {
1250
+ const spliced = node.raw === void 0 ? null : spliceConstruct(node.raw, "{", "}", opts);
1251
+ if (spliced !== null) return spliced;
1252
+ const { options } = node;
1228
1253
  if (options.length === 0) return "";
1229
1254
  const picked = options[randomInt(opts.rng, 0, options.length - 1)];
1230
1255
  if (!picked) return "";
1231
1256
  return { lists: [picked], done: [], assemble: (parts) => parts[0] ?? "" };
1232
1257
  }
1258
+ function spliceConstruct(raw, open, close, opts) {
1259
+ if (opts.frozen === true) return null;
1260
+ const expanded = expandVarsFixpoint(resolveConditionalsInText(raw, opts), opts, passesLeft(opts));
1261
+ const body = resolveConditionalsInText(expanded.text, opts);
1262
+ if (body === raw) return null;
1263
+ const nodes = parseSequence(open + body + close);
1264
+ if (!expanded.converged) return renderNodes(nodes, { ...opts, frozen: true });
1265
+ return { lists: [nodes], done: [], assemble: (parts) => parts[0] ?? "" };
1266
+ }
1233
1267
  function renderPermutation(node, opts) {
1268
+ const spliced = node.raw === void 0 ? null : spliceConstruct(node.raw, "[", "]", opts);
1269
+ if (spliced !== null) return spliced;
1234
1270
  if (node.options.length === 0) return "";
1235
1271
  return {
1236
1272
  lists: node.options.map((o) => o.nodes),