@spintax/core 0.5.0 → 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 +83 -0
- package/dist/index.cjs +38 -20
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +38 -20
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,89 @@
|
|
|
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
|
+
|
|
45
|
+
## 0.5.1 — 2026-08-18
|
|
46
|
+
|
|
47
|
+
Two crashes in the 0.5.0 form-counting path, both reachable from template text, both found by
|
|
48
|
+
review rather than by a failing test. **Upgrade from 0.5.0.**
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
|
|
52
|
+
- **A 62-character template could take `validate()` out with an out-of-memory crash.**
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
#set %a% = %b% %b%
|
|
56
|
+
#set %b% = %a% %a%
|
|
57
|
+
{plural 2: one|%a%}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Counting plural forms expands definitions, and the expansion was bounded by 51 *passes* with
|
|
61
|
+
nothing bounding the *size*. That template doubles the text on every pass, so 51 passes is 2^51.
|
|
62
|
+
Every engine in the family died on it — this one and the Python port with an out-of-memory
|
|
63
|
+
crash, both PHP engines with a memory fatal. A non-cyclic doubling chain does the same, so the
|
|
64
|
+
circular-reference guard never got a chance.
|
|
65
|
+
|
|
66
|
+
The expansion now stops at 64 KB and reports the count as unknowable, which is the same silence
|
|
67
|
+
this validator already uses for every input it cannot pin down. A form list is a handful of
|
|
68
|
+
plural forms; nothing legitimate approaches the ceiling.
|
|
69
|
+
|
|
70
|
+
`validate` is exposed by public HTTP surfaces — this was a remote denial of service, not a
|
|
71
|
+
local footgun.
|
|
72
|
+
|
|
73
|
+
- **`validate()` threw `RangeError` on a long `#set` chain.** The macro walk added in 0.5.0 was
|
|
74
|
+
recursive: roughly 9000 links (a 205 KB template) exhausted the call stack, while the Python
|
|
75
|
+
port gave up at ~1000 links (20 KB) and both PHP engines answered normally — one input, three
|
|
76
|
+
behaviours. The walk is iterative now.
|
|
77
|
+
|
|
78
|
+
It is a pre-order depth-first walk in source order, and it has to stay one: the first non-clean
|
|
79
|
+
answer wins, so a graph holding both an opaque macro and a brackety one gives different
|
|
80
|
+
diagnostics depending on which is met first. Checked by leaving the PHP engine recursive and
|
|
81
|
+
diffing 300 generated macro graphs against it — no mismatches.
|
|
82
|
+
|
|
83
|
+
### Notes
|
|
84
|
+
|
|
85
|
+
No API change and no verdict change for any template that was not crashing. Both fixes are
|
|
86
|
+
mirrored into `spintax/core`, `spintax-core` and the WordPress plugin, and pinned by two corpus
|
|
87
|
+
fixtures whose real assertion is that the engine answers at all — a runner that hangs fails them.
|
|
88
|
+
|
|
6
89
|
## 0.5.0 — 2026-08-18
|
|
7
90
|
|
|
8
91
|
Two plural fixes, both cross-engine, both found by measuring one engine against another rather
|
package/dist/index.cjs
CHANGED
|
@@ -633,6 +633,7 @@ function checkPlurals(text, idx, locale, knownVariables, out) {
|
|
|
633
633
|
var UNRESOLVED_AT_PLURAL_TIME = /\[|\{(?!\?)/u;
|
|
634
634
|
var ANY_BRACKET = /[[\]{}]/u;
|
|
635
635
|
var FORM_EXPANSION_PASSES = 51;
|
|
636
|
+
var FORM_EXPANSION_MAX_GROWTH = 64 * 1024;
|
|
636
637
|
function expandFormsForCounting(formsRaw, defs, macros, hostNames) {
|
|
637
638
|
const unknown = (directMacroSpintax = false) => ({
|
|
638
639
|
forms: 0,
|
|
@@ -640,17 +641,23 @@ function expandFormsForCounting(formsRaw, defs, macros, hostNames) {
|
|
|
640
641
|
directMacroSpintax
|
|
641
642
|
});
|
|
642
643
|
const seenMacro = /* @__PURE__ */ new Set();
|
|
644
|
+
const refsOf = (text2) => [...text2.matchAll(/%(\w+)%/gu)].map((m) => (m[1] ?? "").toLowerCase());
|
|
643
645
|
const walkMacros = (source) => {
|
|
644
|
-
|
|
645
|
-
|
|
646
|
+
const stack = [{ refs: refsOf(source), i: 0 }];
|
|
647
|
+
while (stack.length > 0) {
|
|
648
|
+
const frame = stack[stack.length - 1];
|
|
649
|
+
if (frame.i >= frame.refs.length) {
|
|
650
|
+
stack.pop();
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
const name = frame.refs[frame.i++];
|
|
646
654
|
if (defs[name] !== void 0 || hostNames.has(name)) continue;
|
|
647
655
|
const macro = macros[name];
|
|
648
656
|
if (macro === void 0 || seenMacro.has(name)) continue;
|
|
649
657
|
seenMacro.add(name);
|
|
650
658
|
if (/\{\?/u.test(macro)) return "opaque";
|
|
651
659
|
if (ANY_BRACKET.test(macro)) return "brackets";
|
|
652
|
-
|
|
653
|
-
if (deeper !== "clean") return deeper;
|
|
660
|
+
stack.push({ refs: refsOf(macro), i: 0 });
|
|
654
661
|
}
|
|
655
662
|
return "clean";
|
|
656
663
|
};
|
|
@@ -658,28 +665,33 @@ function expandFormsForCounting(formsRaw, defs, macros, hostNames) {
|
|
|
658
665
|
if (verbatim === "brackets") return unknown(true);
|
|
659
666
|
if (verbatim === "opaque") return unknown();
|
|
660
667
|
let text = formsRaw;
|
|
668
|
+
const budget = formsRaw.length + FORM_EXPANSION_MAX_GROWTH;
|
|
661
669
|
for (let pass = 0; pass < FORM_EXPANSION_PASSES; pass++) {
|
|
662
670
|
let sawReference = false;
|
|
663
671
|
let bailed = false;
|
|
664
|
-
|
|
672
|
+
const parts = [];
|
|
673
|
+
let total = 0;
|
|
674
|
+
let cursor = 0;
|
|
675
|
+
const re = /%(\w+)%/gu;
|
|
676
|
+
let m;
|
|
677
|
+
while ((m = re.exec(text)) !== null) {
|
|
665
678
|
sawReference = true;
|
|
666
|
-
const name =
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
return whole;
|
|
670
|
-
}
|
|
671
|
-
const value = defs[name] ?? macros[name];
|
|
672
|
-
if (value === void 0) {
|
|
673
|
-
bailed = true;
|
|
674
|
-
return whole;
|
|
675
|
-
}
|
|
676
|
-
if (ANY_BRACKET.test(value)) {
|
|
679
|
+
const name = (m[1] ?? "").toLowerCase();
|
|
680
|
+
const value = hostNames.has(name) ? void 0 : defs[name] ?? macros[name];
|
|
681
|
+
if (value === void 0 || ANY_BRACKET.test(value)) {
|
|
677
682
|
bailed = true;
|
|
678
|
-
|
|
683
|
+
break;
|
|
679
684
|
}
|
|
680
|
-
|
|
681
|
-
|
|
685
|
+
parts.push(text.slice(cursor, m.index), value);
|
|
686
|
+
total += m.index - cursor + value.length;
|
|
687
|
+
cursor = m.index + m[0].length;
|
|
688
|
+
if (total > budget) return unknown();
|
|
689
|
+
}
|
|
682
690
|
if (bailed) return unknown();
|
|
691
|
+
parts.push(text.slice(cursor));
|
|
692
|
+
total += text.length - cursor;
|
|
693
|
+
if (total > budget) return unknown();
|
|
694
|
+
text = parts.join("");
|
|
683
695
|
if (!sawReference) {
|
|
684
696
|
return { forms: text.split("|").length, unresolved: false, directMacroSpintax: false };
|
|
685
697
|
}
|
|
@@ -899,6 +911,7 @@ var NotImplementedError = class extends SpintaxError {
|
|
|
899
911
|
|
|
900
912
|
// src/internal/render.ts
|
|
901
913
|
var MAX_VARIABLE_DEPTH = 50;
|
|
914
|
+
var MAX_EXPANSION_CHARS = 1024 * 1024;
|
|
902
915
|
var INCLUDE_LINE_RE = /^[ \t]*#include[ \t\n\r\f\x0B]+"([^"]+)"[ \t\n\r\f\x0B]*$/gmu;
|
|
903
916
|
function renderAst(ast, ctx) {
|
|
904
917
|
const base = buildVars(ast.setDefs, ctx.runtimeContext);
|
|
@@ -906,7 +919,8 @@ function renderAst(ast, ctx) {
|
|
|
906
919
|
rng: ctx.rng,
|
|
907
920
|
locale: ctx.locale,
|
|
908
921
|
depth: 0,
|
|
909
|
-
onPluralError: ctx.onPluralError
|
|
922
|
+
onPluralError: ctx.onPluralError,
|
|
923
|
+
budget: { left: MAX_EXPANSION_CHARS }
|
|
910
924
|
};
|
|
911
925
|
const vars = Object.keys(ast.defDefs).length > 0 ? { ...base, ...rollDefinitions(ast.defDefs, base, ctx.runtimeContext, walkOpts) } : base;
|
|
912
926
|
const text = renderNodes(ast.nodes, { ...walkOpts, vars });
|
|
@@ -1019,6 +1033,8 @@ function resolveVariable(name, opts) {
|
|
|
1019
1033
|
const value = opts.vars[name.toLowerCase()];
|
|
1020
1034
|
if (value === void 0) return `%${name}%`;
|
|
1021
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;
|
|
1022
1038
|
return renderNodes(parseSequence(value), { ...opts, depth: opts.depth + 1 });
|
|
1023
1039
|
}
|
|
1024
1040
|
function expandVarsOnly(text, opts) {
|
|
@@ -1028,6 +1044,8 @@ function expandVarsOnly(text, opts) {
|
|
|
1028
1044
|
out = out.replace(/%(\w+)%/gu, (m, name) => {
|
|
1029
1045
|
const value = opts.vars[name.toLowerCase()];
|
|
1030
1046
|
if (value === void 0) return m;
|
|
1047
|
+
if (opts.budget.left <= 0) return m;
|
|
1048
|
+
opts.budget.left -= value.length;
|
|
1031
1049
|
changed = true;
|
|
1032
1050
|
return value;
|
|
1033
1051
|
});
|