@spintax/core 0.5.3 → 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,48 @@
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
+
6
48
  ## 0.5.3 — 2026-08-18
7
49
 
8
50
  The expansion budget added in 0.5.2 was created per rendered AST, and an `#include` renders one
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);