@spintax/core 0.5.2 → 0.6.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 +67 -0
- package/dist/index.cjs +49 -36
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +49 -36
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,73 @@
|
|
|
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.6.0 — 2026-08-18
|
|
7
|
+
|
|
8
|
+
**`validate()` now emits ONE `variable.circular-reference` per NAME that takes part in, or leads
|
|
9
|
+
to, a cycle** — it used to emit one per PATH (issue #59). Minor rather than patch: diagnostic
|
|
10
|
+
output visibly changes, though no verdict moves and no API does.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
Per-path emission is exponential on a converging diamond, because the number of routes into a
|
|
15
|
+
cycle is exponential in the diamond's depth:
|
|
16
|
+
|
|
17
|
+
| shape | template | before | after |
|
|
18
|
+
|---|---|---|---|
|
|
19
|
+
| 20 definitions, each referencing the previous one twice, feeding a 2-cycle | **507 B** | **2 097 152 diagnostics, 5.9 s** | **22 diagnostics, 6 ms** |
|
|
20
|
+
| the same at depth 200 | 5 KB | (never finished) | 202 diagnostics, ms |
|
|
21
|
+
| live `POST /validate-template` | **547 B** | **HTTP 503** | 200, with a verdict |
|
|
22
|
+
|
|
23
|
+
The second half of the same issue: one cycle of N names printed an N-name route in each of N
|
|
24
|
+
messages. A 43 KB template of one giant cycle carried **29 MB of message text**; the route is now
|
|
25
|
+
capped at 8 names and becomes a count — `n0 → n1 → … → n7 → … (1992 more)` — which brings that to
|
|
26
|
+
200 KB. A real cycle is two to five names and reads exactly as it did.
|
|
27
|
+
|
|
28
|
+
**Messages are unchanged for every shape a human writes.** The colour walk that already existed as
|
|
29
|
+
a prune now also records one witness edge per name, and following those edges reproduces the route
|
|
30
|
+
the per-path walk used to print: `a → b → a`, `d0 → c1 → c2 → c1`. Only the duplicates are gone.
|
|
31
|
+
`Diagnostic.data` carries `{ name }` so a consumer can group without parsing the message.
|
|
32
|
+
|
|
33
|
+
### Notes
|
|
34
|
+
|
|
35
|
+
This reverses a deliberate decision, which is why it is recorded rather than quietly shipped. The
|
|
36
|
+
per-path shape was pinned on purpose in three engines' own suites — "an implementation that
|
|
37
|
+
deduplicates repeated edges, memoizes per root, or reorders roots goes red here, not silently
|
|
38
|
+
green" — and `spintax-win` aligned to it on 2026-08-07. It could not be kept and bounded: re-walking
|
|
39
|
+
every route *is* the emission. `spintax-core` (Python) already emitted per name and was the one
|
|
40
|
+
engine immune; the rest followed it, and its counts are now the reference — verified by diffing 400
|
|
41
|
+
generated definition graphs against it, and against `spintax/core`, with zero mismatches.
|
|
42
|
+
|
|
43
|
+
Mirrored into `spintax/core` 0.8.0 and the WordPress plugin. The corpus gains a fixture for the
|
|
44
|
+
diamond whose real assertion is that the engine *answers*; it cannot gate the count, because
|
|
45
|
+
diagnostics are asserted as a subset — and `packages/conformance/README.md` now says that
|
|
46
|
+
multiplicity is deliberately not part of the contract.
|
|
47
|
+
|
|
48
|
+
## 0.5.3 — 2026-08-18
|
|
49
|
+
|
|
50
|
+
The expansion budget added in 0.5.2 was created per rendered AST, and an `#include` renders one
|
|
51
|
+
— so every include handed the bomb a fresh allowance. Fifty `#include` lines over a single
|
|
52
|
+
62-character body turned **690 bytes into 57 MB**, growing linearly with the include count: the
|
|
53
|
+
bound held for each subtree and bounded nothing overall.
|
|
54
|
+
|
|
55
|
+
### Fixed
|
|
56
|
+
|
|
57
|
+
The budget now belongs to the `render()` call and travels on the render context, includes and
|
|
58
|
+
all. Measured after: 200 includes over the same body produce 1.14 MB, flat, in about 210 ms —
|
|
59
|
+
the same figure one include produces.
|
|
60
|
+
|
|
61
|
+
A host may cap the number of include resolutions (the reference Worker does), but the engine
|
|
62
|
+
must not need it to.
|
|
63
|
+
|
|
64
|
+
### Notes
|
|
65
|
+
|
|
66
|
+
Worth stating plainly, since it decides who is exposed: **this needs an untrusted template
|
|
67
|
+
author, not untrusted data.** The bomb is built from `#set` definitions, and definitions live in
|
|
68
|
+
the template — T1, author-trusted in the §6 model. A context value is T2, and `neutralize()`
|
|
69
|
+
strips `%` from it (`%b% %b%` becomes `b b`), so shielded data cannot carry a reference at all.
|
|
70
|
+
A host that renders its own templates against user data is not reachable; a host that renders
|
|
71
|
+
templates its users write is.
|
|
72
|
+
|
|
6
73
|
## 0.5.2 — 2026-08-18
|
|
7
74
|
|
|
8
75
|
**`render()` no longer dies on a 62-character template** (issue #69). Not a regression — the
|
package/dist/index.cjs
CHANGED
|
@@ -750,9 +750,14 @@ function checkVariableReferences(text, idx, known, out) {
|
|
|
750
750
|
for (const m of value.matchAll(/%(\w+)%/gu)) refs.push((m[1] ?? "").toLowerCase());
|
|
751
751
|
refsOf.set(name, refs);
|
|
752
752
|
}
|
|
753
|
-
const reachesCycle = namesThatReachACycle(defs, refsOf);
|
|
753
|
+
const { reaches: reachesCycle, via } = namesThatReachACycle(defs, refsOf);
|
|
754
754
|
for (const name of defs.keys()) {
|
|
755
|
-
|
|
755
|
+
if (!reachesCycle.has(name)) continue;
|
|
756
|
+
out.push(err(
|
|
757
|
+
"variable.circular-reference",
|
|
758
|
+
`Circular variable reference: ${cyclePath(name, via)}.`,
|
|
759
|
+
{ ...defPos.get(name) ?? somewhere, data: { name } }
|
|
760
|
+
));
|
|
756
761
|
}
|
|
757
762
|
const body = text.replace(/^[ \t]*#(?:set|def)[ \t]+%\w+%[ \t]*=[ \t]*.*?$/gmu, (m) => m.replace(/[^\n]/g, " "));
|
|
758
763
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -776,6 +781,12 @@ function namesThatReachACycle(defs, refsOf) {
|
|
|
776
781
|
const BLACK = 2;
|
|
777
782
|
const color = /* @__PURE__ */ new Map();
|
|
778
783
|
const reaches = /* @__PURE__ */ new Set();
|
|
784
|
+
const via = /* @__PURE__ */ new Map();
|
|
785
|
+
const mark = (name, witness) => {
|
|
786
|
+
if (reaches.has(name)) return;
|
|
787
|
+
reaches.add(name);
|
|
788
|
+
via.set(name, witness);
|
|
789
|
+
};
|
|
779
790
|
for (const root of defs.keys()) {
|
|
780
791
|
if (color.has(root)) continue;
|
|
781
792
|
const stack = [{ name: root, refs: refsOf.get(root) ?? [], i: 0 }];
|
|
@@ -787,9 +798,9 @@ function namesThatReachACycle(defs, refsOf) {
|
|
|
787
798
|
top.i += 1;
|
|
788
799
|
if (ref === top.name || !defs.has(ref)) continue;
|
|
789
800
|
const c = color.get(ref);
|
|
790
|
-
if (c === GREY)
|
|
801
|
+
if (c === GREY) mark(top.name, ref);
|
|
791
802
|
else if (c === BLACK) {
|
|
792
|
-
if (reaches.has(ref))
|
|
803
|
+
if (reaches.has(ref)) mark(top.name, ref);
|
|
793
804
|
} else {
|
|
794
805
|
stack.push({ name: ref, refs: refsOf.get(ref) ?? [], i: 0 });
|
|
795
806
|
color.set(ref, GREY);
|
|
@@ -797,42 +808,41 @@ function namesThatReachACycle(defs, refsOf) {
|
|
|
797
808
|
} else {
|
|
798
809
|
stack.pop();
|
|
799
810
|
color.set(top.name, BLACK);
|
|
800
|
-
if (stack.length > 0 && reaches.has(top.name))
|
|
811
|
+
if (stack.length > 0 && reaches.has(top.name)) mark(stack[stack.length - 1].name, top.name);
|
|
801
812
|
}
|
|
802
813
|
}
|
|
803
814
|
}
|
|
804
|
-
return reaches;
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
const
|
|
809
|
-
const
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
const top = stack[stack.length - 1];
|
|
818
|
-
if (top.i >= top.refs.length) {
|
|
819
|
-
leave(top);
|
|
820
|
-
continue;
|
|
815
|
+
return { reaches, via };
|
|
816
|
+
}
|
|
817
|
+
var CYCLE_PATH_LIMIT = 8;
|
|
818
|
+
function cyclePath(name, via) {
|
|
819
|
+
const seen = /* @__PURE__ */ new Set([name]);
|
|
820
|
+
const shown = [name];
|
|
821
|
+
let current = name;
|
|
822
|
+
for (; ; ) {
|
|
823
|
+
const next = via.get(current);
|
|
824
|
+
if (next === void 0) break;
|
|
825
|
+
if (seen.has(next)) {
|
|
826
|
+
shown.push(next);
|
|
827
|
+
return shown.join(" \u2192 ");
|
|
821
828
|
}
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
path.push(ref);
|
|
829
|
+
if (shown.length >= CYCLE_PATH_LIMIT) {
|
|
830
|
+
let more = 0;
|
|
831
|
+
let walk2 = next;
|
|
832
|
+
while (!seen.has(walk2)) {
|
|
833
|
+
seen.add(walk2);
|
|
834
|
+
more += 1;
|
|
835
|
+
const step = via.get(walk2);
|
|
836
|
+
if (step === void 0) break;
|
|
837
|
+
walk2 = step;
|
|
838
|
+
}
|
|
839
|
+
return `${shown.join(" \u2192 ")} \u2192 \u2026 (${more} more)`;
|
|
834
840
|
}
|
|
841
|
+
seen.add(next);
|
|
842
|
+
shown.push(next);
|
|
843
|
+
current = next;
|
|
835
844
|
}
|
|
845
|
+
return shown.join(" \u2192 ");
|
|
836
846
|
}
|
|
837
847
|
function checkIncludeTargets(text, idx, known, out) {
|
|
838
848
|
const set = new Set(known);
|
|
@@ -920,7 +930,7 @@ function renderAst(ast, ctx) {
|
|
|
920
930
|
locale: ctx.locale,
|
|
921
931
|
depth: 0,
|
|
922
932
|
onPluralError: ctx.onPluralError,
|
|
923
|
-
budget:
|
|
933
|
+
budget: ctx.budget
|
|
924
934
|
};
|
|
925
935
|
const vars = Object.keys(ast.defDefs).length > 0 ? { ...base, ...rollDefinitions(ast.defDefs, base, ctx.runtimeContext, walkOpts) } : base;
|
|
926
936
|
const text = renderNodes(ast.nodes, { ...walkOpts, vars });
|
|
@@ -1360,7 +1370,10 @@ function renderWith(input, rng, opts = {}) {
|
|
|
1360
1370
|
resolver: opts.includeResolver,
|
|
1361
1371
|
maxDepth: opts.maxDepth ?? DEFAULT_MAX_DEPTH,
|
|
1362
1372
|
includeStack: [],
|
|
1363
|
-
onPluralError: opts.onPluralError
|
|
1373
|
+
onPluralError: opts.onPluralError,
|
|
1374
|
+
// One allowance for the whole call. Created here rather than in renderAst because
|
|
1375
|
+
// includes re-enter renderAst, and a budget made there is a budget per subtree.
|
|
1376
|
+
budget: { left: MAX_EXPANSION_CHARS }
|
|
1364
1377
|
};
|
|
1365
1378
|
const ast = resolveAst(input);
|
|
1366
1379
|
let out = renderAst(ast, ctx);
|