@spintax/core 0.3.2 → 0.3.3

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,7 +3,53 @@
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.3.2 — 2026-07-23
6
+ ## 0.3.3 — 2026-08-07
7
+
8
+ `validate()` scales. No output changes anywhere: a 464-document differential over every
9
+ definition-graph, diagnostic-position and fuzz shape is byte-identical before and after, and
10
+ three deliberate mutations (drop the prune, shift a column, freeze the line counter) each
11
+ turn it red — the harness can see what it guards.
12
+
13
+ ### Fixed
14
+
15
+ - **`validate()` is no longer super-linear on definition graphs — and no longer hangs.** Four
16
+ independent costs, each measured before and after (Node 22, same machine):
17
+
18
+ | shape | 0.3.2 | 0.3.3 |
19
+ |---|---|---|
20
+ | chain of 1600 `#set`s | 13.1 s | 18 ms |
21
+ | converging diamond, 20 levels (~1 KB) | 4.8 s | <1 ms |
22
+ | converging diamond, 26+ levels | **never returns** | <1 ms (2000 levels: 22 ms) |
23
+ | one cycle of 1600 | 55.6 s | 0.8 s |
24
+ | 6400 duplicate definitions | 9.3 s | 29 ms |
25
+ | 6400 undefined references | 2.9 s | 11 ms |
26
+
27
+ The circular-reference walk re-parsed every value's references at every visit, tested the
28
+ path with an array `includes`, copied the path at every step, and restarted from every
29
+ definition with no memory of silent subtrees — on a converging diamond that re-explored
30
+ shared subgraphs exponentially, so a one-kilobyte template could pin a CPU forever (the
31
+ validator is exposed by hosts; that is a denial-of-service shape, not just a slow one).
32
+ References are now parsed once, the path is a Set plus a shared push/pop array, the walk is
33
+ iterative (a chain as deep as the document cannot overflow the call stack), and one colour
34
+ walk computes up front which names can reach a cycle — a subtree that cannot reach one
35
+ cannot report, so skipping it is output-neutral by construction. Emission order, count and
36
+ messages are exactly the old walk's, duplicated edges and all.
37
+
38
+ The taint sweep behind `plural.count-macro` was a fixpoint that re-read every macro once per
39
+ newly tainted name; it is now a reverse-edge worklist computing the same closure. Diagnostic
40
+ positions were each computed by scanning from offset 0; a line-start index built once per
41
+ call now answers them by binary search. And `extractDirectives` counted the line of each
42
+ occurrence from the start of the text — quadratic on directive-heavy documents, and on the
43
+ **render** path too, since the renderer extracts directives the same way; a resuming counter
44
+ fixes both surfaces.
45
+
46
+ Two boundaries stay as they are, deliberately. A giant single cycle still costs seconds at
47
+ 6400 definitions because the output itself is quadratic — every diagnostic's message carries
48
+ the full cycle path, so ~300 MB of message text is the answer, not the overhead. And a
49
+ converging diamond that FEEDS a cycle still emits one diagnostic per path — exponentially
50
+ many — because that is the reference emission the corpus family just aligned on
51
+ (spintax-win, 2026-08-07); whether that emission should be bounded is a family question, not
52
+ a patch.
7
53
 
8
54
  Post-process robustness and scaling: `postProcess()` no longer emits its own U+0000 sentinel, and
9
55
  the placeholder restore is linear instead of quadratic. Output is unchanged on ordinary text; the
package/dist/index.cjs CHANGED
@@ -61,6 +61,15 @@ function extractDirectives(text) {
61
61
  const setDefs = {};
62
62
  const defDefs = {};
63
63
  const occurrences = [];
64
+ let cursorOffset = 0;
65
+ let cursorLine = 1;
66
+ const lineAt = (offset) => {
67
+ for (let i = cursorOffset; i < offset; i += 1) {
68
+ if (text.charCodeAt(i) === 10) cursorLine += 1;
69
+ }
70
+ cursorOffset = offset;
71
+ return cursorLine;
72
+ };
64
73
  DIRECTIVE_RE.lastIndex = 0;
65
74
  const stripped = text.replace(
66
75
  DIRECTIVE_RE,
@@ -70,7 +79,7 @@ function extractDirectives(text) {
70
79
  kind,
71
80
  name,
72
81
  value,
73
- line: countLines(text, offset)
82
+ line: lineAt(offset)
74
83
  });
75
84
  if (kind === "def") defDefs[name] = value;
76
85
  else setDefs[name] = value;
@@ -79,11 +88,6 @@ function extractDirectives(text) {
79
88
  );
80
89
  return { body: stripped.replace(/\n{3,}/gu, "\n\n"), setDefs, defDefs, occurrences };
81
90
  }
82
- function countLines(text, offset) {
83
- let line = 1;
84
- for (let i = 0; i < offset; i += 1) if (text[i] === "\n") line += 1;
85
- return line;
86
- }
87
91
  function stripComments(text) {
88
92
  return text.replace(/\/#[\s\S]*?#\//g, "");
89
93
  }
@@ -420,13 +424,14 @@ var INCLUDE_RE = /^[ \t]*#include[ \t\n\r\f\x0B]+"([^"]+)"[ \t\n\r\f\x0B]*$/gmu;
420
424
  function validateTemplate(src, opts = {}) {
421
425
  const diagnostics = [];
422
426
  const text = stripComments(src);
427
+ const idx = buildLineIndex(text);
423
428
  checkBrackets(text, diagnostics);
424
429
  checkDirectives(text, diagnostics);
425
- checkPermutationConfigs(text, diagnostics);
426
- checkPlurals(text, opts.locale, diagnostics);
427
- checkVariableReferences(text, opts.knownVariables, diagnostics);
430
+ checkPermutationConfigs(text, idx, diagnostics);
431
+ checkPlurals(text, idx, opts.locale, diagnostics);
432
+ checkVariableReferences(text, idx, opts.knownVariables, diagnostics);
428
433
  if (opts.knownIncludes && opts.knownIncludes.length > 0) {
429
- checkIncludeTargets(text, opts.knownIncludes, diagnostics);
434
+ checkIncludeTargets(text, idx, opts.knownIncludes, diagnostics);
430
435
  }
431
436
  return diagnostics;
432
437
  }
@@ -436,21 +441,27 @@ function err(code, message, pos) {
436
441
  function warn(code, message, pos) {
437
442
  return { severity: "warning", code, message, ...pos };
438
443
  }
439
- function offsetToLineCol(text, offset) {
440
- let line = 1;
441
- let lineStart = 0;
442
- const end = Math.min(Math.max(offset, 0), text.length);
443
- for (let i = 0; i < end; i += 1) {
444
- if (text.charAt(i) === "\n") {
445
- line += 1;
446
- lineStart = i + 1;
447
- }
444
+ function buildLineIndex(text) {
445
+ const starts = [0];
446
+ for (let i = 0; i < text.length; i += 1) {
447
+ if (text.charCodeAt(i) === 10) starts.push(i + 1);
448
+ }
449
+ return { starts, length: text.length };
450
+ }
451
+ function offsetToLineCol(idx, offset) {
452
+ const end = Math.min(Math.max(offset, 0), idx.length);
453
+ let lo = 0;
454
+ let hi = idx.starts.length - 1;
455
+ while (lo < hi) {
456
+ const mid = lo + hi + 1 >> 1;
457
+ if ((idx.starts[mid] ?? 0) <= end) lo = mid;
458
+ else hi = mid - 1;
448
459
  }
449
- return { line, column: end - lineStart + 1 };
460
+ return { line: lo + 1, column: end - (idx.starts[lo] ?? 0) + 1 };
450
461
  }
451
- function span(text, offset, length) {
452
- const s = offsetToLineCol(text, offset);
453
- const e = offsetToLineCol(text, offset + length);
462
+ function span(idx, offset, length) {
463
+ const s = offsetToLineCol(idx, offset);
464
+ const e = offsetToLineCol(idx, offset + length);
454
465
  return { line: s.line, column: s.column, endLine: e.line, endColumn: e.column };
455
466
  }
456
467
  function checkBrackets(text, out) {
@@ -518,7 +529,7 @@ function checkDirectives(text, out) {
518
529
  }
519
530
  }
520
531
  }
521
- function checkPermutationConfigs(text, out) {
532
+ function checkPermutationConfigs(text, idx, out) {
522
533
  for (const m of text.matchAll(/\[<([^>]*?)>/gu)) {
523
534
  const configStr = m[1] ?? "";
524
535
  if (!/\w+\s*=/.test(configStr)) continue;
@@ -529,7 +540,7 @@ function checkPermutationConfigs(text, out) {
529
540
  out.push(err(
530
541
  "permutation.unknown-key",
531
542
  `Unknown permutation config key: '${km[1]}'.`,
532
- { ...span(text, configBase + (km.index ?? 0), (km[1] ?? "").length), data: { key: km[1] } }
543
+ { ...span(idx, configBase + (km.index ?? 0), (km[1] ?? "").length), data: { key: km[1] } }
533
544
  ));
534
545
  }
535
546
  }
@@ -538,7 +549,7 @@ function checkPermutationConfigs(text, out) {
538
549
  out.push(err(
539
550
  "permutation.minsize-not-integer",
540
551
  `minsize must be a positive integer, got '${min[1]}'.`,
541
- { ...span(text, configBase + min.index, min[0].length), data: { value: min[1] } }
552
+ { ...span(idx, configBase + min.index, min[0].length), data: { value: min[1] } }
542
553
  ));
543
554
  }
544
555
  const max = /maxsize\s*=\s*([^;>\s]+)/i.exec(configStr);
@@ -546,17 +557,17 @@ function checkPermutationConfigs(text, out) {
546
557
  out.push(err(
547
558
  "permutation.maxsize-not-integer",
548
559
  `maxsize must be a positive integer, got '${max[1]}'.`,
549
- { ...span(text, configBase + max.index, max[0].length), data: { value: max[1] } }
560
+ { ...span(idx, configBase + max.index, max[0].length), data: { value: max[1] } }
550
561
  ));
551
562
  }
552
563
  }
553
564
  }
554
- function checkPlurals(text, locale, out) {
565
+ function checkPlurals(text, idx, locale, out) {
555
566
  const base = locale && locale !== "" ? normalizeBaseLang(locale) : "";
556
567
  const arity = base !== "" ? pluralArity(base) : 0;
557
568
  const tainted = macroTaintedNames(text);
558
569
  for (const block of findPluralBlocks(text)) {
559
- const at = span(text, block.start, block.end - block.start);
570
+ const at = span(idx, block.start, block.end - block.start);
560
571
  for (const m of block.countSlot.matchAll(/%(\w+)%/gu)) {
561
572
  const name = (m[1] ?? "").toLowerCase();
562
573
  if (!tainted.has(name)) continue;
@@ -586,25 +597,34 @@ var UNRESOLVED_AT_PLURAL_TIME = /\[|\{(?!\?)/u;
586
597
  function macroTaintedNames(text) {
587
598
  const macros = extractDirectives(text).setDefs;
588
599
  const tainted = /* @__PURE__ */ new Set();
600
+ const queue = [];
601
+ for (const [name, value] of Object.entries(macros)) {
602
+ if (UNRESOLVED_AT_PLURAL_TIME.test(value)) {
603
+ tainted.add(name);
604
+ queue.push(name);
605
+ }
606
+ }
607
+ const dependents = /* @__PURE__ */ new Map();
589
608
  for (const [name, value] of Object.entries(macros)) {
590
- if (UNRESOLVED_AT_PLURAL_TIME.test(value)) tainted.add(name);
609
+ for (const m of value.matchAll(/%(\w+)%/gu)) {
610
+ const ref = (m[1] ?? "").toLowerCase();
611
+ const list = dependents.get(ref);
612
+ if (list === void 0) dependents.set(ref, [name]);
613
+ else list.push(name);
614
+ }
591
615
  }
592
- let grew = true;
593
- while (grew) {
594
- grew = false;
595
- for (const [name, value] of Object.entries(macros)) {
596
- if (tainted.has(name)) continue;
597
- for (const m of value.matchAll(/%(\w+)%/gu)) {
598
- if (!tainted.has((m[1] ?? "").toLowerCase())) continue;
616
+ while (queue.length > 0) {
617
+ const source = queue.pop();
618
+ for (const name of dependents.get(source) ?? []) {
619
+ if (!tainted.has(name)) {
599
620
  tainted.add(name);
600
- grew = true;
601
- break;
621
+ queue.push(name);
602
622
  }
603
623
  }
604
624
  }
605
625
  return tainted;
606
626
  }
607
- function checkVariableReferences(text, known, out) {
627
+ function checkVariableReferences(text, idx, known, out) {
608
628
  const knownSet = new Set((known ?? []).map((n) => n.toLowerCase()));
609
629
  const defs = /* @__PURE__ */ new Map();
610
630
  const defPos = /* @__PURE__ */ new Map();
@@ -612,7 +632,7 @@ function checkVariableReferences(text, known, out) {
612
632
  const name = (m[1] ?? "").toLowerCase();
613
633
  defs.set(name, m[2] ?? "");
614
634
  const nameOffset = (m.index ?? 0) + m[0].indexOf("%");
615
- defPos.set(name, span(text, nameOffset, name.length + 2));
635
+ defPos.set(name, span(idx, nameOffset, name.length + 2));
616
636
  }
617
637
  const somewhere = { line: 1, column: 1 };
618
638
  for (const [name, value] of defs) {
@@ -620,8 +640,15 @@ function checkVariableReferences(text, known, out) {
620
640
  out.push(err("variable.self-reference", `Variable '${name}' references itself.`, defPos.get(name) ?? somewhere));
621
641
  }
622
642
  }
643
+ const refsOf = /* @__PURE__ */ new Map();
644
+ for (const [name, value] of defs) {
645
+ const refs = [];
646
+ for (const m of value.matchAll(/%(\w+)%/gu)) refs.push((m[1] ?? "").toLowerCase());
647
+ refsOf.set(name, refs);
648
+ }
649
+ const reachesCycle = namesThatReachACycle(defs, refsOf);
623
650
  for (const name of defs.keys()) {
624
- detectCycle(name, defs, [name], defPos.get(name) ?? somewhere, out);
651
+ detectCycle(name, defs, refsOf, reachesCycle, defPos.get(name) ?? somewhere, out);
625
652
  }
626
653
  const body = text.replace(/^[ \t]*#(?:set|def)[ \t]+%\w+%[ \t]*=[ \t]*.*?$/gmu, (m) => m.replace(/[^\n]/g, " "));
627
654
  const seen = /* @__PURE__ */ new Set();
@@ -632,7 +659,7 @@ function checkVariableReferences(text, known, out) {
632
659
  out.push(warn(
633
660
  "variable.undefined",
634
661
  `Variable '${name}' is not defined \u2014 may be a runtime variable.`,
635
- { ...span(text, offset, length), data: { name } }
662
+ { ...span(idx, offset, length), data: { name } }
636
663
  ));
637
664
  };
638
665
  for (const m of body.matchAll(/%(\w+)%/gu)) undefinedAt(m[1] ?? "", m.index ?? 0, m[0].length);
@@ -640,19 +667,70 @@ function checkVariableReferences(text, known, out) {
640
667
  undefinedAt(m[1] ?? "", (m.index ?? 0) + m[0].indexOf(m[1] ?? ""), (m[1] ?? "").length);
641
668
  }
642
669
  }
643
- function detectCycle(current, defs, visited, rootPos, out) {
644
- const value = defs.get(current) ?? "";
645
- for (const m of value.matchAll(/%(\w+)%/gu)) {
646
- const ref = (m[1] ?? "").toLowerCase();
647
- if (ref === current) continue;
648
- if (visited.includes(ref)) {
649
- out.push(err("variable.circular-reference", `Circular variable reference: ${[...visited, ref].join(" \u2192 ")}.`, rootPos));
650
- return;
670
+ function namesThatReachACycle(defs, refsOf) {
671
+ const GREY = 1;
672
+ const BLACK = 2;
673
+ const color = /* @__PURE__ */ new Map();
674
+ const reaches = /* @__PURE__ */ new Set();
675
+ for (const root of defs.keys()) {
676
+ if (color.has(root)) continue;
677
+ const stack = [{ name: root, refs: refsOf.get(root) ?? [], i: 0 }];
678
+ color.set(root, GREY);
679
+ while (stack.length > 0) {
680
+ const top = stack[stack.length - 1];
681
+ if (top.i < top.refs.length) {
682
+ const ref = top.refs[top.i];
683
+ top.i += 1;
684
+ if (ref === top.name || !defs.has(ref)) continue;
685
+ const c = color.get(ref);
686
+ if (c === GREY) reaches.add(top.name);
687
+ else if (c === BLACK) {
688
+ if (reaches.has(ref)) reaches.add(top.name);
689
+ } else {
690
+ stack.push({ name: ref, refs: refsOf.get(ref) ?? [], i: 0 });
691
+ color.set(ref, GREY);
692
+ }
693
+ } else {
694
+ stack.pop();
695
+ color.set(top.name, BLACK);
696
+ if (stack.length > 0 && reaches.has(top.name)) reaches.add(stack[stack.length - 1].name);
697
+ }
698
+ }
699
+ }
700
+ return reaches;
701
+ }
702
+ function detectCycle(root, defs, refsOf, reachesCycle, rootPos, out) {
703
+ if (!reachesCycle.has(root)) return;
704
+ const path = [root];
705
+ const onPath = /* @__PURE__ */ new Set([root]);
706
+ const stack = [{ name: root, refs: refsOf.get(root) ?? [], i: 0 }];
707
+ const leave = (frame) => {
708
+ stack.pop();
709
+ onPath.delete(frame.name);
710
+ path.pop();
711
+ };
712
+ while (stack.length > 0) {
713
+ const top = stack[stack.length - 1];
714
+ if (top.i >= top.refs.length) {
715
+ leave(top);
716
+ continue;
717
+ }
718
+ const ref = top.refs[top.i];
719
+ top.i += 1;
720
+ if (ref === top.name) continue;
721
+ if (onPath.has(ref)) {
722
+ out.push(err("variable.circular-reference", `Circular variable reference: ${[...path, ref].join(" \u2192 ")}.`, rootPos));
723
+ leave(top);
724
+ continue;
725
+ }
726
+ if (defs.has(ref) && reachesCycle.has(ref)) {
727
+ stack.push({ name: ref, refs: refsOf.get(ref) ?? [], i: 0 });
728
+ onPath.add(ref);
729
+ path.push(ref);
651
730
  }
652
- if (defs.has(ref)) detectCycle(ref, defs, [...visited, ref], rootPos, out);
653
731
  }
654
732
  }
655
- function checkIncludeTargets(text, known, out) {
733
+ function checkIncludeTargets(text, idx, known, out) {
656
734
  const set = new Set(known);
657
735
  INCLUDE_RE.lastIndex = 0;
658
736
  for (const m of text.matchAll(INCLUDE_RE)) {
@@ -662,7 +740,7 @@ function checkIncludeTargets(text, known, out) {
662
740
  out.push(err(
663
741
  "include.unknown-target",
664
742
  `#include target '${ref}' does not match any known template.`,
665
- { ...span(text, refOffset, ref.length), data: { target: ref } }
743
+ { ...span(idx, refOffset, ref.length), data: { target: ref } }
666
744
  ));
667
745
  }
668
746
  }