@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 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
- detectCycle(name, defs, refsOf, reachesCycle, defPos.get(name) ?? somewhere, out);
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) reaches.add(top.name);
801
+ if (c === GREY) mark(top.name, ref);
791
802
  else if (c === BLACK) {
792
- if (reaches.has(ref)) reaches.add(top.name);
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)) reaches.add(stack[stack.length - 1].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
- function detectCycle(root, defs, refsOf, reachesCycle, rootPos, out) {
807
- if (!reachesCycle.has(root)) return;
808
- const path = [root];
809
- const onPath = /* @__PURE__ */ new Set([root]);
810
- const stack = [{ name: root, refs: refsOf.get(root) ?? [], i: 0 }];
811
- const leave = (frame) => {
812
- stack.pop();
813
- onPath.delete(frame.name);
814
- path.pop();
815
- };
816
- while (stack.length > 0) {
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
- const ref = top.refs[top.i];
823
- top.i += 1;
824
- if (ref === top.name) continue;
825
- if (onPath.has(ref)) {
826
- out.push(err("variable.circular-reference", `Circular variable reference: ${[...path, ref].join(" \u2192 ")}.`, rootPos));
827
- leave(top);
828
- continue;
829
- }
830
- if (defs.has(ref) && reachesCycle.has(ref)) {
831
- stack.push({ name: ref, refs: refsOf.get(ref) ?? [], i: 0 });
832
- onPath.add(ref);
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: { left: MAX_EXPANSION_CHARS }
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);