@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 +47 -1
- package/dist/index.cjs +132 -54
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +132 -54
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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.
|
|
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:
|
|
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
|
|
440
|
-
|
|
441
|
-
let
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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 -
|
|
460
|
+
return { line: lo + 1, column: end - (idx.starts[lo] ?? 0) + 1 };
|
|
450
461
|
}
|
|
451
|
-
function span(
|
|
452
|
-
const s = offsetToLineCol(
|
|
453
|
-
const e = offsetToLineCol(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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(
|
|
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
|
|
644
|
-
const
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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(
|
|
743
|
+
{ ...span(idx, refOffset, ref.length), data: { target: ref } }
|
|
666
744
|
));
|
|
667
745
|
}
|
|
668
746
|
}
|