@spintax/core 0.5.1 → 0.5.2

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,45 @@
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.5.2 — 2026-08-18
7
+
8
+ **`render()` no longer dies on a 62-character template** (issue #69). Not a regression — the
9
+ published 0.3.4 does the same, and so did every engine in the family. Live, this was HTTP 503
10
+ from the reference Worker.
11
+
12
+ ### Fixed
13
+
14
+ ```
15
+ #set %a% = %b% %b%
16
+ #set %b% = %a% %a%
17
+ %a%
18
+ ```
19
+
20
+ Each expansion replaces one reference with two, so the 50-level depth cap allows 2^50 and the
21
+ process ends: an out-of-memory abort here and in the Python port, a memory fatal in both PHP
22
+ engines. Acyclic doubling definitions do it too, so the cycle guard never fires. **Any**
23
+ reference to such a value reaches it — plain text, a plural slot, an enumeration, a
24
+ permutation. Defining the pair is harmless, and a conditional testing one is harmless, because
25
+ neither expands the value.
26
+
27
+ A render may now expand at most 1 MB of `%variable%` text. Past that a reference is left
28
+ literal — exactly what an undefined name already does, so no new output shape appears, render
29
+ stays lenient (§9.2), and a plural whose count did not resolve erases as it always has. The
30
+ ceiling is far above any real document; the point is to end an explosion, not to ration output.
31
+
32
+ ### Notes
33
+
34
+ **What a truncated explosion looks like is deliberately NOT parity-gated,** and the conformance
35
+ README now says so. The engines expand by different mechanisms — a per-reference tree walk here
36
+ and in Python, a whole-text fixpoint in both PHP engines — so they stop in different places and
37
+ produce different byte counts for the same bomb (1 198 223 against 599 191, measured). What is
38
+ contract, and what each engine pins in its own suite, is that render terminates, does not
39
+ throw, bounds its output, and leaves what it could not afford as literal `%name%`. Making the
40
+ byte counts agree would mean rewriting one engine's expansion to match another's traversal, for
41
+ input no author writes.
42
+
43
+ This is the render-side twin of the counting bomb fixed in 0.5.1; that one bounds `validate()`.
44
+
6
45
  ## 0.5.1 — 2026-08-18
7
46
 
8
47
  Two crashes in the 0.5.0 form-counting path, both reachable from template text, both found by
package/dist/index.cjs CHANGED
@@ -911,6 +911,7 @@ var NotImplementedError = class extends SpintaxError {
911
911
 
912
912
  // src/internal/render.ts
913
913
  var MAX_VARIABLE_DEPTH = 50;
914
+ var MAX_EXPANSION_CHARS = 1024 * 1024;
914
915
  var INCLUDE_LINE_RE = /^[ \t]*#include[ \t\n\r\f\x0B]+"([^"]+)"[ \t\n\r\f\x0B]*$/gmu;
915
916
  function renderAst(ast, ctx) {
916
917
  const base = buildVars(ast.setDefs, ctx.runtimeContext);
@@ -918,7 +919,8 @@ function renderAst(ast, ctx) {
918
919
  rng: ctx.rng,
919
920
  locale: ctx.locale,
920
921
  depth: 0,
921
- onPluralError: ctx.onPluralError
922
+ onPluralError: ctx.onPluralError,
923
+ budget: { left: MAX_EXPANSION_CHARS }
922
924
  };
923
925
  const vars = Object.keys(ast.defDefs).length > 0 ? { ...base, ...rollDefinitions(ast.defDefs, base, ctx.runtimeContext, walkOpts) } : base;
924
926
  const text = renderNodes(ast.nodes, { ...walkOpts, vars });
@@ -1031,6 +1033,8 @@ function resolveVariable(name, opts) {
1031
1033
  const value = opts.vars[name.toLowerCase()];
1032
1034
  if (value === void 0) return `%${name}%`;
1033
1035
  if (opts.depth >= MAX_VARIABLE_DEPTH || !/[{[%]/u.test(value)) return value;
1036
+ if (opts.budget.left <= 0) return `%${name}%`;
1037
+ opts.budget.left -= value.length;
1034
1038
  return renderNodes(parseSequence(value), { ...opts, depth: opts.depth + 1 });
1035
1039
  }
1036
1040
  function expandVarsOnly(text, opts) {
@@ -1040,6 +1044,8 @@ function expandVarsOnly(text, opts) {
1040
1044
  out = out.replace(/%(\w+)%/gu, (m, name) => {
1041
1045
  const value = opts.vars[name.toLowerCase()];
1042
1046
  if (value === void 0) return m;
1047
+ if (opts.budget.left <= 0) return m;
1048
+ opts.budget.left -= value.length;
1043
1049
  changed = true;
1044
1050
  return value;
1045
1051
  });