@rhost/testkit 1.5.1 → 1.5.2

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.
Files changed (157) hide show
  1. package/node_modules/@ursamu/mushcode/.github/workflows/publish.yml +36 -0
  2. package/node_modules/@ursamu/mushcode/LICENSE +21 -0
  3. package/node_modules/@ursamu/mushcode/README.md +110 -0
  4. package/node_modules/@ursamu/mushcode/_dist/mod.d.ts +36 -0
  5. package/node_modules/@ursamu/mushcode/_dist/mod.d.ts.map +1 -0
  6. package/node_modules/@ursamu/mushcode/_dist/parser/mod.d.ts +41 -0
  7. package/node_modules/@ursamu/mushcode/_dist/parser/mod.d.ts.map +1 -0
  8. package/node_modules/@ursamu/mushcode/_dist/src/analyze/commands.d.ts +15 -0
  9. package/node_modules/@ursamu/mushcode/_dist/src/analyze/commands.d.ts.map +1 -0
  10. package/node_modules/@ursamu/mushcode/_dist/src/analyze/deps.d.ts +18 -0
  11. package/node_modules/@ursamu/mushcode/_dist/src/analyze/deps.d.ts.map +1 -0
  12. package/node_modules/@ursamu/mushcode/_dist/src/analyze/mod.d.ts +20 -0
  13. package/node_modules/@ursamu/mushcode/_dist/src/analyze/mod.d.ts.map +1 -0
  14. package/node_modules/@ursamu/mushcode/_dist/src/analyze/tags.d.ts +6 -0
  15. package/node_modules/@ursamu/mushcode/_dist/src/analyze/tags.d.ts.map +1 -0
  16. package/node_modules/@ursamu/mushcode/_dist/src/eval/context.d.ts +85 -0
  17. package/node_modules/@ursamu/mushcode/_dist/src/eval/context.d.ts.map +1 -0
  18. package/node_modules/@ursamu/mushcode/_dist/src/eval/engine.d.ts +48 -0
  19. package/node_modules/@ursamu/mushcode/_dist/src/eval/engine.d.ts.map +1 -0
  20. package/node_modules/@ursamu/mushcode/_dist/src/eval/mod.d.ts +26 -0
  21. package/node_modules/@ursamu/mushcode/_dist/src/eval/mod.d.ts.map +1 -0
  22. package/node_modules/@ursamu/mushcode/_dist/src/eval/stdlib/mod.d.ts +3 -0
  23. package/node_modules/@ursamu/mushcode/_dist/src/eval/stdlib/mod.d.ts.map +1 -0
  24. package/node_modules/@ursamu/mushcode/_dist/src/lint/mod.d.ts +38 -0
  25. package/node_modules/@ursamu/mushcode/_dist/src/lint/mod.d.ts.map +1 -0
  26. package/node_modules/@ursamu/mushcode/_dist/src/print/mod.d.ts +18 -0
  27. package/node_modules/@ursamu/mushcode/_dist/src/print/mod.d.ts.map +1 -0
  28. package/node_modules/@ursamu/mushcode/_dist/src/print/printer.d.ts +15 -0
  29. package/node_modules/@ursamu/mushcode/_dist/src/print/printer.d.ts.map +1 -0
  30. package/node_modules/@ursamu/mushcode/_dist/src/traverse/mod.d.ts +19 -0
  31. package/node_modules/@ursamu/mushcode/_dist/src/traverse/mod.d.ts.map +1 -0
  32. package/node_modules/@ursamu/mushcode/_dist/src/traverse/transform.d.ts +27 -0
  33. package/node_modules/@ursamu/mushcode/_dist/src/traverse/transform.d.ts.map +1 -0
  34. package/node_modules/@ursamu/mushcode/_dist/src/traverse/walk.d.ts +27 -0
  35. package/node_modules/@ursamu/mushcode/_dist/src/traverse/walk.d.ts.map +1 -0
  36. package/node_modules/@ursamu/mushcode/deno.json +26 -0
  37. package/node_modules/@ursamu/mushcode/deno.lock +42 -0
  38. package/node_modules/@ursamu/mushcode/docs/analyze.md +145 -0
  39. package/node_modules/@ursamu/mushcode/docs/eval.md +312 -0
  40. package/node_modules/@ursamu/mushcode/docs/lint.md +152 -0
  41. package/node_modules/@ursamu/mushcode/docs/parser.md +196 -0
  42. package/node_modules/@ursamu/mushcode/docs/print.md +84 -0
  43. package/node_modules/@ursamu/mushcode/docs/stdlib.md +418 -0
  44. package/node_modules/@ursamu/mushcode/docs/traverse.md +167 -0
  45. package/node_modules/@ursamu/mushcode/grammar/mux-softcode.pegjs +781 -0
  46. package/node_modules/@ursamu/mushcode/mod.js +44 -0
  47. package/node_modules/@ursamu/mushcode/mod.js.map +1 -0
  48. package/node_modules/@ursamu/mushcode/mod.ts +63 -0
  49. package/node_modules/@ursamu/mushcode/package.json +38 -0
  50. package/node_modules/@ursamu/mushcode/parser/mod.js +47 -0
  51. package/node_modules/@ursamu/mushcode/parser/mod.js.map +1 -0
  52. package/node_modules/@ursamu/mushcode/parser/mod.ts +99 -0
  53. package/node_modules/@ursamu/mushcode/parser/mux-softcode.js +3833 -0
  54. package/node_modules/@ursamu/mushcode/parser/mux-softcode.mjs +3837 -0
  55. package/node_modules/@ursamu/mushcode/src/analyze/commands.js +29 -0
  56. package/node_modules/@ursamu/mushcode/src/analyze/commands.js.map +1 -0
  57. package/node_modules/@ursamu/mushcode/src/analyze/commands.ts +46 -0
  58. package/node_modules/@ursamu/mushcode/src/analyze/deps.js +45 -0
  59. package/node_modules/@ursamu/mushcode/src/analyze/deps.js.map +1 -0
  60. package/node_modules/@ursamu/mushcode/src/analyze/deps.ts +51 -0
  61. package/node_modules/@ursamu/mushcode/src/analyze/mod.js +18 -0
  62. package/node_modules/@ursamu/mushcode/src/analyze/mod.js.map +1 -0
  63. package/node_modules/@ursamu/mushcode/src/analyze/mod.ts +20 -0
  64. package/node_modules/@ursamu/mushcode/src/analyze/tags.js +11 -0
  65. package/node_modules/@ursamu/mushcode/src/analyze/tags.js.map +1 -0
  66. package/node_modules/@ursamu/mushcode/src/analyze/tags.ts +11 -0
  67. package/node_modules/@ursamu/mushcode/src/eval/context.js +22 -0
  68. package/node_modules/@ursamu/mushcode/src/eval/context.js.map +1 -0
  69. package/node_modules/@ursamu/mushcode/src/eval/context.ts +177 -0
  70. package/node_modules/@ursamu/mushcode/src/eval/engine.js +238 -0
  71. package/node_modules/@ursamu/mushcode/src/eval/engine.js.map +1 -0
  72. package/node_modules/@ursamu/mushcode/src/eval/engine.ts +276 -0
  73. package/node_modules/@ursamu/mushcode/src/eval/mod.js +25 -0
  74. package/node_modules/@ursamu/mushcode/src/eval/mod.js.map +1 -0
  75. package/node_modules/@ursamu/mushcode/src/eval/mod.ts +31 -0
  76. package/node_modules/@ursamu/mushcode/src/eval/stdlib/compare.js +56 -0
  77. package/node_modules/@ursamu/mushcode/src/eval/stdlib/compare.js.map +1 -0
  78. package/node_modules/@ursamu/mushcode/src/eval/stdlib/compare.ts +16 -0
  79. package/node_modules/@ursamu/mushcode/src/eval/stdlib/db.js +91 -0
  80. package/node_modules/@ursamu/mushcode/src/eval/stdlib/db.js.map +1 -0
  81. package/node_modules/@ursamu/mushcode/src/eval/stdlib/db.ts +104 -0
  82. package/node_modules/@ursamu/mushcode/src/eval/stdlib/iter.js +91 -0
  83. package/node_modules/@ursamu/mushcode/src/eval/stdlib/iter.js.map +1 -0
  84. package/node_modules/@ursamu/mushcode/src/eval/stdlib/iter.ts +98 -0
  85. package/node_modules/@ursamu/mushcode/src/eval/stdlib/logic.js +79 -0
  86. package/node_modules/@ursamu/mushcode/src/eval/stdlib/logic.js.map +1 -0
  87. package/node_modules/@ursamu/mushcode/src/eval/stdlib/logic.ts +84 -0
  88. package/node_modules/@ursamu/mushcode/src/eval/stdlib/math.js +120 -0
  89. package/node_modules/@ursamu/mushcode/src/eval/stdlib/math.js.map +1 -0
  90. package/node_modules/@ursamu/mushcode/src/eval/stdlib/math.ts +115 -0
  91. package/node_modules/@ursamu/mushcode/src/eval/stdlib/mod.js +17 -0
  92. package/node_modules/@ursamu/mushcode/src/eval/stdlib/mod.js.map +1 -0
  93. package/node_modules/@ursamu/mushcode/src/eval/stdlib/mod.ts +19 -0
  94. package/node_modules/@ursamu/mushcode/src/eval/stdlib/register.js +28 -0
  95. package/node_modules/@ursamu/mushcode/src/eval/stdlib/register.js.map +1 -0
  96. package/node_modules/@ursamu/mushcode/src/eval/stdlib/register.ts +31 -0
  97. package/node_modules/@ursamu/mushcode/src/eval/stdlib/string.js +153 -0
  98. package/node_modules/@ursamu/mushcode/src/eval/stdlib/string.js.map +1 -0
  99. package/node_modules/@ursamu/mushcode/src/eval/stdlib/string.ts +154 -0
  100. package/node_modules/@ursamu/mushcode/src/lint/builtin_arities.js +212 -0
  101. package/node_modules/@ursamu/mushcode/src/lint/builtin_arities.js.map +1 -0
  102. package/node_modules/@ursamu/mushcode/src/lint/builtin_arities.ts +68 -0
  103. package/node_modules/@ursamu/mushcode/src/lint/mod.js +60 -0
  104. package/node_modules/@ursamu/mushcode/src/lint/mod.js.map +1 -0
  105. package/node_modules/@ursamu/mushcode/src/lint/mod.ts +96 -0
  106. package/node_modules/@ursamu/mushcode/src/lint/rules/arg_count.js +37 -0
  107. package/node_modules/@ursamu/mushcode/src/lint/rules/arg_count.js.map +1 -0
  108. package/node_modules/@ursamu/mushcode/src/lint/rules/arg_count.ts +44 -0
  109. package/node_modules/@ursamu/mushcode/src/lint/rules/iter_var_outside_iter.js +55 -0
  110. package/node_modules/@ursamu/mushcode/src/lint/rules/iter_var_outside_iter.js.map +1 -0
  111. package/node_modules/@ursamu/mushcode/src/lint/rules/iter_var_outside_iter.ts +60 -0
  112. package/node_modules/@ursamu/mushcode/src/lint/rules/missing_wildcard.js +31 -0
  113. package/node_modules/@ursamu/mushcode/src/lint/rules/missing_wildcard.js.map +1 -0
  114. package/node_modules/@ursamu/mushcode/src/lint/rules/missing_wildcard.ts +40 -0
  115. package/node_modules/@ursamu/mushcode/src/lint/rules/register_before_set.js +59 -0
  116. package/node_modules/@ursamu/mushcode/src/lint/rules/register_before_set.js.map +1 -0
  117. package/node_modules/@ursamu/mushcode/src/lint/rules/register_before_set.ts +64 -0
  118. package/node_modules/@ursamu/mushcode/src/print/lock_printer.js +43 -0
  119. package/node_modules/@ursamu/mushcode/src/print/lock_printer.js.map +1 -0
  120. package/node_modules/@ursamu/mushcode/src/print/lock_printer.ts +41 -0
  121. package/node_modules/@ursamu/mushcode/src/print/mod.js +17 -0
  122. package/node_modules/@ursamu/mushcode/src/print/mod.js.map +1 -0
  123. package/node_modules/@ursamu/mushcode/src/print/mod.ts +18 -0
  124. package/node_modules/@ursamu/mushcode/src/print/printer.js +91 -0
  125. package/node_modules/@ursamu/mushcode/src/print/printer.js.map +1 -0
  126. package/node_modules/@ursamu/mushcode/src/print/printer.ts +132 -0
  127. package/node_modules/@ursamu/mushcode/src/traverse/child_slots.js +129 -0
  128. package/node_modules/@ursamu/mushcode/src/traverse/child_slots.js.map +1 -0
  129. package/node_modules/@ursamu/mushcode/src/traverse/child_slots.ts +51 -0
  130. package/node_modules/@ursamu/mushcode/src/traverse/mod.js +17 -0
  131. package/node_modules/@ursamu/mushcode/src/traverse/mod.js.map +1 -0
  132. package/node_modules/@ursamu/mushcode/src/traverse/mod.ts +19 -0
  133. package/node_modules/@ursamu/mushcode/src/traverse/transform.js +70 -0
  134. package/node_modules/@ursamu/mushcode/src/traverse/transform.js.map +1 -0
  135. package/node_modules/@ursamu/mushcode/src/traverse/transform.ts +84 -0
  136. package/node_modules/@ursamu/mushcode/src/traverse/walk.js +55 -0
  137. package/node_modules/@ursamu/mushcode/src/traverse/walk.js.map +1 -0
  138. package/node_modules/@ursamu/mushcode/src/traverse/walk.ts +82 -0
  139. package/node_modules/@ursamu/mushcode/tests/01-literals.test.ts +105 -0
  140. package/node_modules/@ursamu/mushcode/tests/02-substitutions.test.ts +145 -0
  141. package/node_modules/@ursamu/mushcode/tests/03-function-calls.test.ts +184 -0
  142. package/node_modules/@ursamu/mushcode/tests/04-eval-blocks.test.ts +110 -0
  143. package/node_modules/@ursamu/mushcode/tests/05-braced-strings.test.ts +119 -0
  144. package/node_modules/@ursamu/mushcode/tests/06-commands.test.ts +222 -0
  145. package/node_modules/@ursamu/mushcode/tests/07-dollar-patterns.test.ts +156 -0
  146. package/node_modules/@ursamu/mushcode/tests/08-lock-expressions.test.ts +159 -0
  147. package/node_modules/@ursamu/mushcode/tests/09-edge-cases.test.ts +162 -0
  148. package/node_modules/@ursamu/mushcode/tests/10-regression.test.ts +211 -0
  149. package/node_modules/@ursamu/mushcode/tests/11-tags.test.ts +357 -0
  150. package/node_modules/@ursamu/mushcode/tests/12-locations.test.ts +162 -0
  151. package/node_modules/@ursamu/mushcode/tests/13-eval.test.ts +389 -0
  152. package/node_modules/@ursamu/mushcode/tests/analyze.test.ts +194 -0
  153. package/node_modules/@ursamu/mushcode/tests/helpers.ts +69 -0
  154. package/node_modules/@ursamu/mushcode/tests/lint.test.ts +232 -0
  155. package/node_modules/@ursamu/mushcode/tests/print.test.ts +204 -0
  156. package/node_modules/@ursamu/mushcode/tests/traverse.test.ts +211 -0
  157. package/package.json +4 -1
@@ -0,0 +1,389 @@
1
+ // ============================================================================
2
+ // 13 — EvalEngine: stdlib functions, substitutions, and error handling
3
+ // ============================================================================
4
+
5
+ import { assertEquals } from "jsr:@std/assert@^1";
6
+ import { describe, it } from "jsr:/@std/testing@^1/bdd";
7
+ import { EvalEngine, makeContext, registerStdlib } from "../src/eval/mod.ts";
8
+ import type { EvalContext, ObjectAccessor } from "../src/eval/mod.ts";
9
+
10
+ // ── Test fixture ──────────────────────────────────────────────────────────────
11
+
12
+ const mockAccessor: ObjectAccessor = {
13
+ getAttr(objectId, attr) {
14
+ const db: Record<string, Record<string, string>> = {
15
+ obj1: {
16
+ NAME: "Alice",
17
+ SCORE: "42",
18
+ FN_ADD: "[add(%0,%1)]",
19
+ FN_GREET: "Hello, %0!",
20
+ FN_DEEP: "[u(me/FN_ADD,%0,%1)]", // wraps FN_ADD for depth test
21
+ FN_RECUR: "[u(me/FN_RECUR,%0)]", // infinite recursion — hits depth limit
22
+ },
23
+ obj2: { NAME: "Bob", SCORE: "7" },
24
+ };
25
+ return Promise.resolve(db[objectId]?.[attr.toUpperCase()] ?? null);
26
+ },
27
+ resolveTarget(_from, expr) {
28
+ if (expr === "me" || expr === "obj1") return Promise.resolve("obj1");
29
+ if (expr === "obj2" || expr === "Bob") return Promise.resolve("obj2");
30
+ return Promise.resolve(null);
31
+ },
32
+ getName(objectId) {
33
+ if (objectId === "obj1") return Promise.resolve("Alice");
34
+ if (objectId === "obj2") return Promise.resolve("Bob");
35
+ return Promise.resolve(objectId);
36
+ },
37
+ hasFlag(_id, flag) {
38
+ return Promise.resolve(flag === "wizard" && _id === "obj1");
39
+ },
40
+ };
41
+
42
+ function makeEngine(): EvalEngine {
43
+ const e = new EvalEngine(mockAccessor);
44
+ registerStdlib(e);
45
+ return e;
46
+ }
47
+
48
+ function ctx(overrides: Partial<EvalContext> = {}): EvalContext {
49
+ return makeContext({ enactor: "obj1", executor: "obj1", ...overrides });
50
+ }
51
+
52
+ function ev(src: string, overrides: Partial<EvalContext> = {}): Promise<string> {
53
+ return makeEngine().evalString(src, ctx(overrides));
54
+ }
55
+
56
+ // ── Math ──────────────────────────────────────────────────────────────────────
57
+
58
+ describe("eval — math", () => {
59
+ it("add(1,2) = 3", async () => assertEquals(await ev("[add(1,2)]"), "3"));
60
+ it("add variadic", async () => assertEquals(await ev("[add(1,2,3)]"), "6"));
61
+ it("sub(5,3) = 2", async () => assertEquals(await ev("[sub(5,3)]"), "2"));
62
+ it("mul(3,4) = 12", async () => assertEquals(await ev("[mul(3,4)]"), "12"));
63
+ it("div(6,2) = 3 (integer)", async () => assertEquals(await ev("[div(6,2)]"), "3"));
64
+ it("div(5,2) = 2 (truncate)", async () => assertEquals(await ev("[div(5,2)]"), "2"));
65
+ it("mod(7,3) = 1", async () => assertEquals(await ev("[mod(7,3)]"), "1"));
66
+ it("abs(-5) = 5", async () => assertEquals(await ev("[abs(-5)]"), "5"));
67
+ it("floor(3.7) = 3", async () => assertEquals(await ev("[floor(3.7)]"), "3"));
68
+ it("ceil(3.2) = 4", async () => assertEquals(await ev("[ceil(3.2)]"), "4"));
69
+ it("round(3.567,2) = 3.57", async () => assertEquals(await ev("[round(3.567,2)]"),"3.57"));
70
+ it("max(1,5,3) = 5", async () => assertEquals(await ev("[max(1,5,3)]"), "5"));
71
+ it("min(1,5,3) = 1", async () => assertEquals(await ev("[min(1,5,3)]"), "1"));
72
+ it("power(2,10) = 1024", async () => assertEquals(await ev("[power(2,10)]"), "1024"));
73
+ it("sqrt(4) = 2", async () => assertEquals(await ev("[sqrt(4)]"), "2"));
74
+ it("div by zero → #-1", async () => assertEquals(await ev("[div(1,0)]"), "#-1 DIVIDE BY ZERO"));
75
+ it("sqrt(-1) → #-1", async () => assertEquals(await ev("[sqrt(-1)]"), "#-1 ARGUMENT OUT OF RANGE"));
76
+ it("non-number → #-1", async () => assertEquals(await ev("[add(x,1)]"), "#-1 ARGUMENT (X) IS NOT A NUMBER"));
77
+ });
78
+
79
+ // ── String ────────────────────────────────────────────────────────────────────
80
+
81
+ describe("eval — string", () => {
82
+ it("strlen", async () => assertEquals(await ev("[strlen(hello)]"), "5"));
83
+ it("mid 0-based", async () => assertEquals(await ev("[mid(hello,1,3)]"), "ell"));
84
+ it("left", async () => assertEquals(await ev("[left(hello,3)]"), "hel"));
85
+ it("right", async () => assertEquals(await ev("[right(hello,3)]"), "llo"));
86
+ it("trim both", async () => assertEquals(await ev("[trim( hello )]"), "hello"));
87
+ it("trim left", async () => assertEquals(await ev("[trim( hello ,l)]"), "hello "));
88
+ it("trim right", async () => assertEquals(await ev("[trim( hello ,r)]"), " hello"));
89
+ it("ucstr", async () => assertEquals(await ev("[ucstr(hello)]"), "HELLO"));
90
+ it("lcstr", async () => assertEquals(await ev("[lcstr(HELLO)]"), "hello"));
91
+ it("capstr", async () => assertEquals(await ev("[capstr(hello world)]"), "Hello world"));
92
+ it("cat joins", async () => assertEquals(await ev("[cat(hello,world)]"), "hello world"));
93
+ it("space(3)", async () => assertEquals(await ev("[space(3)]"), " "));
94
+ it("repeat", async () => assertEquals(await ev("[repeat(ab,3)]"), "ababab"));
95
+ it("ljust", async () => assertEquals(await ev("[ljust(hi,5)]"), "hi "));
96
+ it("rjust", async () => assertEquals(await ev("[rjust(hi,5)]"), " hi"));
97
+ it("center even", async () => assertEquals(await ev("[center(hi,6)]"), " hi "));
98
+ });
99
+
100
+ // ── Compare ───────────────────────────────────────────────────────────────────
101
+
102
+ describe("eval — compare", () => {
103
+ it("eq match", async () => assertEquals(await ev("[eq(1,1)]"), "1"));
104
+ it("eq no match",async () => assertEquals(await ev("[eq(1,2)]"), "0"));
105
+ it("neq", async () => assertEquals(await ev("[neq(1,2)]"), "1"));
106
+ it("gt true", async () => assertEquals(await ev("[gt(3,2)]"), "1"));
107
+ it("gt false", async () => assertEquals(await ev("[gt(2,3)]"), "0"));
108
+ it("lt true", async () => assertEquals(await ev("[lt(2,3)]"), "1"));
109
+ it("gte equal", async () => assertEquals(await ev("[gte(3,3)]"), "1"));
110
+ it("lte equal", async () => assertEquals(await ev("[lte(3,3)]"), "1"));
111
+ });
112
+
113
+ // ── Logic ─────────────────────────────────────────────────────────────────────
114
+
115
+ describe("eval — logic", () => {
116
+ it("t(1) = 1", async () => assertEquals(await ev("[t(1)]"), "1"));
117
+ it("t(0) = 0", async () => assertEquals(await ev("[t(0)]"), "0"));
118
+ it("not(1) = 0", async () => assertEquals(await ev("[not(1)]"), "0"));
119
+ it("not(0) = 1", async () => assertEquals(await ev("[not(0)]"), "1"));
120
+ it("if true branch", async () => assertEquals(await ev("[if(1,yes)]"), "yes"));
121
+ it("if false branch = empty", async () => assertEquals(await ev("[if(0,yes)]"), ""));
122
+ it("if false with else", async () => assertEquals(await ev("[if(0,yes,no)]"), "no"));
123
+ it("ifelse true", async () => assertEquals(await ev("[ifelse(1,yes,no)]"), "yes"));
124
+ it("ifelse false", async () => assertEquals(await ev("[ifelse(0,yes,no)]"), "no"));
125
+ it("switch finds match", async () => assertEquals(await ev("[switch(b,a,first,b,second,nope)]"),"second"));
126
+ it("switch uses default", async () => assertEquals(await ev("[switch(x,a,one,b,two,default)]"), "default"));
127
+ it("switch no default no match",async () => assertEquals(await ev("[switch(x,a,one,b,two)]"), ""));
128
+ it("and both true", async () => assertEquals(await ev("[and(1,1)]"), "1"));
129
+ it("and short-circuit false", async () => assertEquals(await ev("[and(1,0)]"), "0"));
130
+ it("or first true", async () => assertEquals(await ev("[or(0,1)]"), "1"));
131
+ it("or all false", async () => assertEquals(await ev("[or(0,0)]"), "0"));
132
+ });
133
+
134
+ // ── Registers ─────────────────────────────────────────────────────────────────
135
+
136
+ describe("eval — registers", () => {
137
+ it("setq then r", async () => assertEquals(await ev("[setq(0,hello)][r(0)]"), "hello"));
138
+ it("setr returns value", async () => assertEquals(await ev("[setr(0,world)]"), "world"));
139
+ it("setr visible via r", async () => assertEquals(await ev("[setr(x,foo)][r(x)]"), "foofoo"));
140
+ it("r unset = empty", async () => assertEquals(await ev("[r(zzz)]"), ""));
141
+ it("%q shorthand", async () => assertEquals(await ev("[setq(k,hi)]%qk"), "hi"));
142
+ });
143
+
144
+ // ── Iter ──────────────────────────────────────────────────────────────────────
145
+
146
+ describe("eval — iter / list", () => {
147
+ it("words space-delimited", async () => assertEquals(await ev("[words(a b c)]"), "3"));
148
+ it("words single word", async () => assertEquals(await ev("[words(hello)]"), "1"));
149
+ it("word(str,2)", async () => assertEquals(await ev("[word(a b c,2)]"), "b"));
150
+ it("first", async () => assertEquals(await ev("[first(a b c)]"), "a"));
151
+ it("last", async () => assertEquals(await ev("[last(a b c)]"), "c"));
152
+ it("rest", async () => assertEquals(await ev("[rest(a b c)]"), "b c"));
153
+ it("rest single word", async () => assertEquals(await ev("[rest(a)]"), ""));
154
+ it("iter body with ##", async () => assertEquals(await ev("[iter(a b c,##!)]"), "a! b! c!"));
155
+ it("iter index with #@", async () => assertEquals(await ev("[iter(a b c,#@)]"), "1 2 3"));
156
+ it("iter empty list", async () => assertEquals(await ev("[iter(,body)]"), ""));
157
+ });
158
+
159
+ // ── Context substitutions ─────────────────────────────────────────────────────
160
+
161
+ describe("eval — substitutions", () => {
162
+ it("%# enactor", async () => assertEquals(await ev("%#"), "obj1"));
163
+ it("%! executor", async () => assertEquals(await ev("%!"), "obj1"));
164
+ it("%0 first arg", async () => assertEquals(await ev("%0", { args: ["hi"] }), "hi"));
165
+ it("%1 second arg", async () => assertEquals(await ev("%1", { args: ["a","b"]}), "b"));
166
+ it("%+ arg count", async () => assertEquals(await ev("%+", { args: ["a","b"]}), "2"));
167
+ it("%r newline", async () => assertEquals(await ev("%r"), "\r\n"));
168
+ it("%t tab", async () => assertEquals(await ev("%t"), "\t"));
169
+ it("%b space", async () => assertEquals(await ev("%b"), " "));
170
+ it("%% literal", async () => assertEquals(await ev("%%"), "%"));
171
+ it("%N name", async () => assertEquals(await ev("%N"), "Alice"));
172
+ it("%n name lower", async () => assertEquals(await ev("%n"), "alice"));
173
+ it("## iter frame", async () => assertEquals(await ev("[iter(x,##)]"), "x"));
174
+ it("#@ iter index", async () => assertEquals(await ev("[iter(x,#@)]"), "1"));
175
+ });
176
+
177
+ // ── Error handling ────────────────────────────────────────────────────────────
178
+
179
+ describe("eval — error handling", () => {
180
+ it("unknown fn → #-1", async () => assertEquals(await ev("[nope()]"), "#-1 FUNCTION (nope) NOT FOUND"));
181
+ it("too few args → #-1", async () => assertEquals(await ev("[add(1)]"), "#-1 FUNCTION (add) REQUIRES AT LEAST 2 ARGUMENT(S)"));
182
+ it("too many args → #-1",async () => assertEquals(await ev("[sub(1,2,3)]"), "#-1 FUNCTION (sub) TAKES AT MOST 2 ARGUMENT(S)"));
183
+ it("depth exceeded", async () => {
184
+ const engine = makeEngine();
185
+ const c = ctx({ depth: 101, maxDepth: 100 });
186
+ assertEquals(await engine.evalString("[add(1,2)]", c), "#-1 EVALUATION DEPTH EXCEEDED");
187
+ });
188
+ it("abort signal", async () => {
189
+ const engine = makeEngine();
190
+ const ac = new AbortController();
191
+ ac.abort();
192
+ let threw = false;
193
+ try { await engine.evalString("[add(1,2)]", ctx({ signal: ac.signal })); }
194
+ catch (e) { threw = (e as DOMException).name === "AbortError"; }
195
+ assertEquals(threw, true);
196
+ });
197
+ });
198
+
199
+ // ── Math edge cases ───────────────────────────────────────────────────────────
200
+
201
+ describe("eval — math edge cases", () => {
202
+ it("float add avoids precision noise", async () => assertEquals(await ev("[add(0.1,0.2)]"), "0.3"));
203
+ it("negative mod", async () => assertEquals(await ev("[mod(-7,3)]"), "-1"));
204
+ it("div negative truncates toward 0", async () => assertEquals(await ev("[div(-5,2)]"), "-2"));
205
+ it("mul variadic", async () => assertEquals(await ev("[mul(2,3,4)]"), "24"));
206
+ it("power(0,0) = 1", async () => assertEquals(await ev("[power(0,0)]"), "1"));
207
+ it("abs of 0", async () => assertEquals(await ev("[abs(0)]"), "0"));
208
+ it("round to 0 decimals", async () => assertEquals(await ev("[round(2.5,0)]"), "3"));
209
+ it("sqrt of 0", async () => assertEquals(await ev("[sqrt(0)]"), "0"));
210
+ it("large integers stay integers", async () => assertEquals(await ev("[add(99999999,1)]"), "100000000"));
211
+ });
212
+
213
+ // ── String edge cases ─────────────────────────────────────────────────────────
214
+
215
+ describe("eval — string edge cases", () => {
216
+ it("mid negative start clamps to 0", async () => assertEquals(await ev("[mid(hello,-1,3)]"), "hel"));
217
+ it("mid start past end = empty", async () => assertEquals(await ev("[mid(hello,10,3)]"), ""));
218
+ it("mid len=0 = empty", async () => assertEquals(await ev("[mid(hello,1,0)]"), ""));
219
+ it("left 0 = empty", async () => assertEquals(await ev("[left(hello,0)]"), ""));
220
+ it("left more than length", async () => assertEquals(await ev("[left(hi,100)]"), "hi"));
221
+ it("right 0 = empty", async () => assertEquals(await ev("[right(hello,0)]"), ""));
222
+ it("right more than length", async () => assertEquals(await ev("[right(hi,100)]"), "hi"));
223
+ it("strlen space is 1", async () => assertEquals(await ev("[strlen(%b)]"), "1"));
224
+ it("ljust no-op when str >= width", async () => assertEquals(await ev("[ljust(hello,3)]"), "hello"));
225
+ it("center odd padding puts extra on right", async () => assertEquals(await ev("[center(hi,5)]"), " hi "));
226
+ it("trim custom char", async () => assertEquals(await ev("[trim(xxhelloxx,b,x)]"), "hello"));
227
+ it("repeat 0 times = empty", async () => assertEquals(await ev("[repeat(ab,0)]"), ""));
228
+ it("space 0 = empty", async () => assertEquals(await ev("[space(0)]"), ""));
229
+ it("capstr single char", async () => assertEquals(await ev("[capstr(a)]"), "A"));
230
+ });
231
+
232
+ // ── Logic edge cases ──────────────────────────────────────────────────────────
233
+
234
+ describe("eval — logic edge cases", () => {
235
+ it("nested if: if inside condition", async () => assertEquals(await ev("[if([eq(1,1)],yes,no)]"), "yes"));
236
+ it("ifelse with computed condition", async () => assertEquals(await ev("[ifelse([gt(5,3)],big,small)]"), "big"));
237
+ it("and short-circuits: second not evaluated if first false", async () => {
238
+ // If short-circuit works, [div(1,0)] is never evaluated
239
+ assertEquals(await ev("[and(0,[div(1,0)])]"), "0");
240
+ });
241
+ it("or short-circuits: second not evaluated if first true", async () => {
242
+ assertEquals(await ev("[or(1,[div(1,0)])]"), "1");
243
+ });
244
+ it("switch empty value matches empty pattern", async () => assertEquals(await ev("[switch(,,yes)]"), "yes"));
245
+ it("t of non-zero non-empty = 1", async () => assertEquals(await ev("[t(abc)]"), "1"));
246
+ it("t of -1 = 1 (truthy)", async () => assertEquals(await ev("[t(-1)]"), "1"));
247
+ });
248
+
249
+ // ── Iter edge cases ───────────────────────────────────────────────────────────
250
+
251
+ describe("eval — iter edge cases", () => {
252
+ it("iter custom delimiter", async () => assertEquals(await ev("[iter(a|b|c,##,|)]"), "a b c"));
253
+ it("iter custom out delim", async () => assertEquals(await ev("[iter(a|b,##,|,.)]"), "a.b"));
254
+ it("iter nested: inner ## = inner item", async () =>
255
+ assertEquals(await ev("[iter(a b,[iter(1 2,##)])]"), "1 2 1 2"));
256
+ it("iter nested: outer ## via %i1", async () =>
257
+ assertEquals(await ev("[iter(x y,[iter(1 2,%i1)])]"), "x x y y"));
258
+ it("rest with custom delim", async () => assertEquals(await ev("[rest(a|b|c,|)]"), "b|c"));
259
+ it("word out of range = empty", async () => assertEquals(await ev("[word(a b c,9)]"), ""));
260
+ it("words multiple spaces", async () => assertEquals(await ev("[words(a b c)]"), "3"));
261
+ });
262
+
263
+ // ── Substitution edge cases ───────────────────────────────────────────────────
264
+
265
+ describe("eval — substitution edge cases", () => {
266
+ it("%: = enactor (objid alias)", async () => assertEquals(await ev("%:"), "obj1"));
267
+ it("%@ = empty when no caller", async () => assertEquals(await ev("%@"), ""));
268
+ it("%0 empty when no args", async () => assertEquals(await ev("%0"), ""));
269
+ it("%9 empty when only 2 args", async () => assertEquals(await ev("%9", { args: ["a","b"] }), ""));
270
+ it("%=NAME reads enactor attr", async () => assertEquals(await ev("%=NAME"), "Alice"));
271
+ it("%=SCORE reads numeric attr", async () => assertEquals(await ev("%=SCORE"), "42"));
272
+ it("%=NOEXIST = empty", async () => assertEquals(await ev("%=NOEXIST"), ""));
273
+ it("%i0 = ## when inside iter", async () => assertEquals(await ev("[iter(z,%i0)]"), "z"));
274
+ it("ANSI %xr passes through", async () => assertEquals(await ev("%xr"), "%xr"));
275
+ it("ANSI %cn passes through", async () => assertEquals(await ev("%cn"), "%cn"));
276
+ });
277
+
278
+ // ── DB functions ──────────────────────────────────────────────────────────────
279
+
280
+ describe("eval — get / name / hasattr / hasflag", () => {
281
+ it("get(obj1/SCORE) = 42", async () => assertEquals(await ev("[get(obj1/SCORE)]"), "42"));
282
+ it("get(me/NAME) = Alice", async () => assertEquals(await ev("[get(me/NAME)]"), "Alice"));
283
+ it("get unset attr = empty", async () => assertEquals(await ev("[get(me/NOPE)]"), ""));
284
+ it("get no-match target → #-1", async () => assertEquals(await ev("[get(zzz/SCORE)]"), "#-1 NO MATCH"));
285
+ it("get no slash → #-1 format", async () => assertEquals(await ev("[get(just_text)]"), "#-1 BAD ARGUMENT FORMAT"));
286
+ it("name(obj1) = Alice", async () => assertEquals(await ev("[name(obj1)]"), "Alice"));
287
+ it("name(me) = Alice", async () => assertEquals(await ev("[name(me)]"), "Alice"));
288
+ it("name no match → #-1", async () => assertEquals(await ev("[name(zzz)]"), "#-1 NO MATCH"));
289
+ it("hasattr yes → 1", async () => assertEquals(await ev("[hasattr(me,NAME)]"), "1"));
290
+ it("hasattr no → 0", async () => assertEquals(await ev("[hasattr(me,NOPE)]"), "0"));
291
+ it("hasattr bad target → 0", async () => assertEquals(await ev("[hasattr(zzz,NAME)]"), "0"));
292
+ it("hasflag wizard on obj1 → 1", async () => assertEquals(await ev("[hasflag(me,wizard)]"),"1"));
293
+ it("hasflag builder on obj1 → 0", async () => assertEquals(await ev("[hasflag(me,builder)]"),"0"));
294
+ });
295
+
296
+ // ── u() function ─────────────────────────────────────────────────────────────
297
+
298
+ describe("eval — u()", () => {
299
+ it("u(me/FN_GREET,%0) with arg", async () => assertEquals(await ev("[u(me/FN_GREET,World)]"), "Hello, World!"));
300
+ it("u(me/FN_ADD,%0,%1)", async () => assertEquals(await ev("[u(me/FN_ADD,3,4)]"), "7"));
301
+ it("u no-slash uses executor", async () => assertEquals(await ev("[u(FN_GREET,Test)]"), "Hello, Test!"));
302
+ it("u no match target → #-1", async () => assertEquals(await ev("[u(zzz/FN_ADD,1,2)]"), "#-1 NO MATCH"));
303
+ it("u missing attr → #-1", async () => assertEquals(await ev("[u(me/NOPE)]"), "#-1 NO SUCH ATTRIBUTE"));
304
+ it("u increments depth", async () => assertEquals(await ev("[u(me/FN_DEEP,5,6)]"), "11"));
305
+ it("u infinite recursion hits limit",async () => assertEquals(await ev("[u(me/FN_RECUR,x)]"), "#-1 EVALUATION DEPTH EXCEEDED"));
306
+ it("u child registers are fresh", async () => {
307
+ // setq inside u() must not leak into outer context
308
+ const result = await ev("[setq(0,outer)][u(me/FN_ADD,1,2)][r(0)]");
309
+ assertEquals(result, "3outer"); // FN_ADD returns "3", outer r(0) still "outer"
310
+ });
311
+ it("u %@ = outer executor", async () => {
312
+ // FN_CALLER attr returns %@ (the caller)
313
+ const engine = makeEngine();
314
+ engine.accessor; // just touch it
315
+ // Register a custom attr getter that exposes %@
316
+ const customAccessor: ObjectAccessor = {
317
+ ...mockAccessor,
318
+ getAttr(id, attr) {
319
+ if (attr === "FN_CALLER") return Promise.resolve("%@");
320
+ return mockAccessor.getAttr(id, attr);
321
+ },
322
+ };
323
+ const e = new EvalEngine(customAccessor);
324
+ registerStdlib(e);
325
+ const result = await e.evalString("[u(me/FN_CALLER)]", ctx());
326
+ assertEquals(result, "obj1"); // caller = previous executor = obj1
327
+ });
328
+ });
329
+
330
+ // ── Security: resource exhaustion ────────────────────────────────────────────
331
+ // RED tests written before the patch — they must fail until M-1 and M-2 are fixed.
332
+
333
+ describe("eval — security: unbounded allocation (M-1)", () => {
334
+ it("repeat with huge n → #-1", async () => {
335
+ // 50 000 reps × 1 char = 50 KB — must be capped, not allocated
336
+ assertEquals(await ev("[repeat(A,50000)]"), "#-1 OUTPUT TOO LONG");
337
+ });
338
+
339
+ it("space with huge n → #-1", async () => {
340
+ assertEquals(await ev("[space(50000)]"), "#-1 OUTPUT TOO LONG");
341
+ });
342
+
343
+ it("ljust with huge width → #-1", async () => {
344
+ assertEquals(await ev("[ljust(x,50000)]"), "#-1 OUTPUT TOO LONG");
345
+ });
346
+
347
+ it("rjust with huge width → #-1", async () => {
348
+ assertEquals(await ev("[rjust(x,50000)]"), "#-1 OUTPUT TOO LONG");
349
+ });
350
+
351
+ it("center with huge width → #-1", async () => {
352
+ assertEquals(await ev("[center(x,50000)]"), "#-1 OUTPUT TOO LONG");
353
+ });
354
+
355
+ // Boundary: MAX_STRING_LEN itself is allowed
356
+ it("repeat exactly MAX_STRING_LEN = ok", async () => {
357
+ const result = await ev("[strlen([repeat(A,8000)])]");
358
+ assertEquals(result, "8000");
359
+ });
360
+
361
+ it("space exactly MAX_STRING_LEN = ok", async () => {
362
+ const result = await ev("[strlen([space(8000)])]");
363
+ assertEquals(result, "8000");
364
+ });
365
+ });
366
+
367
+ describe("eval — security: output budget (M-2)", () => {
368
+ it("concatenating chunks beyond maxOutputLen → #-1", async () => {
369
+ const engine = makeEngine();
370
+ // 4 × 300 = 1200 chars; budget is 1000 — must be rejected
371
+ const c = ctx({ maxOutputLen: 1_000 });
372
+ const result = await engine.evalString(
373
+ "[repeat(A,300)][repeat(A,300)][repeat(A,300)][repeat(A,300)]",
374
+ c,
375
+ );
376
+ assertEquals(result, "#-1 OUTPUT LIMIT EXCEEDED");
377
+ });
378
+
379
+ it("output within budget is returned normally", async () => {
380
+ const engine = makeEngine();
381
+ const c = ctx({ maxOutputLen: 1_000 });
382
+ // 3 × 300 = 900 < 1000 — should succeed
383
+ const result = await engine.evalString(
384
+ "[repeat(A,300)][repeat(A,300)][repeat(A,300)]",
385
+ c,
386
+ );
387
+ assertEquals(result.length, 900);
388
+ });
389
+ });
@@ -0,0 +1,194 @@
1
+ // ============================================================================
2
+ // Analyze — extractCommands, extractDeps, extractTagRefs
3
+ // ============================================================================
4
+
5
+ import { assertEquals } from "jsr:@std/assert@^1";
6
+ import { describe, it } from "jsr:/@std/testing@^1/bdd";
7
+ import { parse } from "../parser/mod.ts";
8
+ import type { ASTNode } from "../parser/mod.ts";
9
+ import { extractCommands, extractDeps, extractTagRefs } from "../src/analyze/mod.ts";
10
+
11
+ // ── Helpers ────────────────────────────────────────────────────────────────────
12
+
13
+ function p(text: string): ASTNode {
14
+ return parse(text, "Start");
15
+ }
16
+
17
+ // ── extractCommands ────────────────────────────────────────────────────────────
18
+
19
+ describe("extractCommands", () => {
20
+ it("returns empty array for plain text", () => {
21
+ assertEquals(extractCommands(p("hello")), []);
22
+ });
23
+
24
+ it("extracts a single DollarPattern", () => {
25
+ const ast = p("$+finger *:@pemit %#=%0");
26
+ const cmds = extractCommands(ast);
27
+ assertEquals(cmds.length, 1);
28
+ assertEquals(cmds[0].type, "dollar");
29
+ assertEquals(cmds[0].patternText, "+finger *");
30
+ assertEquals(cmds[0].pattern.type, "Pattern");
31
+ assertEquals(cmds[0].action.type, "AtCommand");
32
+ });
33
+
34
+ it("extracts a single ListenPattern", () => {
35
+ const ast = p("^hello *:@pemit %#=Hi!");
36
+ const cmds = extractCommands(ast);
37
+ assertEquals(cmds.length, 1);
38
+ assertEquals(cmds[0].type, "listen");
39
+ assertEquals(cmds[0].patternText, "hello *");
40
+ });
41
+
42
+ it("extracts multiple patterns from a manually built CommandList", () => {
43
+ // In practice each $-trigger is its own attribute value; we build
44
+ // a synthetic CommandList to verify extractCommands walks the whole tree.
45
+ const ast: ASTNode = {
46
+ type: "CommandList",
47
+ commands: [
48
+ p("$+look:look"),
49
+ p("$+who:doing"),
50
+ ],
51
+ };
52
+ const cmds = extractCommands(ast);
53
+ assertEquals(cmds.length, 2);
54
+ assertEquals(cmds[0].type, "dollar");
55
+ assertEquals(cmds[1].type, "dollar");
56
+ });
57
+
58
+ it("extracts both dollar and listen patterns from a synthetic tree", () => {
59
+ const ast: ASTNode = {
60
+ type: "CommandList",
61
+ commands: [
62
+ p("$say *:@pemit %#=%0"),
63
+ p("^hello:@pemit %#=hi"),
64
+ ],
65
+ };
66
+ const cmds = extractCommands(ast);
67
+ const types = cmds.map(c => c.type);
68
+ assertEquals(types.includes("dollar"), true);
69
+ assertEquals(types.includes("listen"), true);
70
+ });
71
+
72
+ it("pattern node is the Pattern/PatternAlts node", () => {
73
+ const ast = p("$+finger *:@pemit %#=%0");
74
+ const cmds = extractCommands(ast);
75
+ const ptype = cmds[0].pattern.type;
76
+ assertEquals(ptype === "Pattern" || ptype === "PatternAlts", true);
77
+ });
78
+
79
+ it("extracts PatternAlts correctly", () => {
80
+ const ast = p("$look;+look:look");
81
+ const cmds = extractCommands(ast);
82
+ assertEquals(cmds.length, 1);
83
+ assertEquals(cmds[0].patternText.includes("look"), true);
84
+ });
85
+ });
86
+
87
+ // ── extractDeps ────────────────────────────────────────────────────────────────
88
+
89
+ describe("extractDeps", () => {
90
+ it("returns empty array for plain text", () => {
91
+ assertEquals(extractDeps(p("hello")), []);
92
+ });
93
+
94
+ it("detects u() call", () => {
95
+ const ast = p("[u(me/FN_FINGER,%0)]");
96
+ const deps = extractDeps(ast);
97
+ assertEquals(deps.length, 1);
98
+ assertEquals(deps[0].type, "u");
99
+ assertEquals(deps[0].target, "me/FN_FINGER");
100
+ });
101
+
102
+ it("detects get() call", () => {
103
+ const ast = p("[get(me/MYATTR)]");
104
+ const deps = extractDeps(ast);
105
+ assertEquals(deps.length, 1);
106
+ assertEquals(deps[0].type, "get");
107
+ assertEquals(deps[0].target, "me/MYATTR");
108
+ });
109
+
110
+ it("detects v() call (treated as get)", () => {
111
+ const ast = p("[v(MYATTR)]");
112
+ const deps = extractDeps(ast);
113
+ assertEquals(deps.length, 1);
114
+ assertEquals(deps[0].type, "get");
115
+ assertEquals(deps[0].target, "MYATTR");
116
+ });
117
+
118
+ it("detects @trigger command", () => {
119
+ const ast = p("@trigger me/GO_ACTION=arg");
120
+ const deps = extractDeps(ast);
121
+ assertEquals(deps.length, 1);
122
+ assertEquals(deps[0].type, "trigger");
123
+ assertEquals(deps[0].target, "me/GO_ACTION");
124
+ });
125
+
126
+ it("collects multiple deps from one attribute", () => {
127
+ const ast = p("[u(me/FN1)][get(me/ATTR)][v(OTHER)]");
128
+ const deps = extractDeps(ast);
129
+ assertEquals(deps.length, 3);
130
+ const types = deps.map(d => d.type);
131
+ assertEquals(types.filter(t => t === "u").length, 1);
132
+ assertEquals(types.filter(t => t === "get").length, 2);
133
+ });
134
+
135
+ it("ignores non-dep function calls like strlen()", () => {
136
+ const ast = p("[strlen(hello)]");
137
+ const deps = extractDeps(ast);
138
+ assertEquals(deps.length, 0);
139
+ });
140
+
141
+ it("dynamic target (eval in first arg) is included as-printed", () => {
142
+ const ast = p("[u([switch(%0,a,me,#5)]/FUNC)]");
143
+ const deps = extractDeps(ast);
144
+ assertEquals(deps.length, 1);
145
+ assertEquals(deps[0].type, "u");
146
+ // target is the printed first arg — contains the eval block
147
+ assertEquals(deps[0].target.includes("["), true);
148
+ });
149
+
150
+ it("case-insensitive: U() and GET() are detected", () => {
151
+ const ast = p("[U(me/FN)][GET(me/A)]");
152
+ const deps = extractDeps(ast);
153
+ assertEquals(deps.length, 2);
154
+ });
155
+ });
156
+
157
+ // ── extractTagRefs ─────────────────────────────────────────────────────────────
158
+
159
+ describe("extractTagRefs", () => {
160
+ it("returns empty array when no TagRefs", () => {
161
+ assertEquals(extractTagRefs(p("hello")), []);
162
+ });
163
+
164
+ it("extracts a single TagRef", () => {
165
+ const ast = p("[tag(me,#combat)]");
166
+ const tags = extractTagRefs(ast);
167
+ assertEquals(tags, ["combat"]);
168
+ });
169
+
170
+ it("deduplicates repeated TagRefs", () => {
171
+ const ast = p("[tag(me,#combat)][tag(me,#combat)]");
172
+ const tags = extractTagRefs(ast);
173
+ assertEquals(tags, ["combat"]);
174
+ });
175
+
176
+ it("collects multiple distinct TagRefs sorted alphabetically", () => {
177
+ const ast = p("[tag(me,#zebra)][tag(me,#alpha)][tag(me,#middle)]");
178
+ const tags = extractTagRefs(ast);
179
+ assertEquals(tags, ["alpha", "middle", "zebra"]);
180
+ });
181
+
182
+ it("detects TagRef in attribute set value", () => {
183
+ const ast = p("&ATTR me=[tag(me,#mytag)]");
184
+ const tags = extractTagRefs(ast);
185
+ assertEquals(tags, ["mytag"]);
186
+ });
187
+
188
+ it("tagmatch() references", () => {
189
+ const ast = p("[tagmatch(me,#hero,#npc)]");
190
+ const tags = extractTagRefs(ast);
191
+ assertEquals(tags.includes("hero"), true);
192
+ assertEquals(tags.includes("npc"), true);
193
+ });
194
+ });
@@ -0,0 +1,69 @@
1
+ // ============================================================================
2
+ // Shared test helpers for mux-softcode-parser e2e tests
3
+ // ============================================================================
4
+
5
+ import { parse, type ASTNode, ParseError } from "../parser/mod.ts";
6
+ export { parse, ParseError };
7
+ export type { ASTNode };
8
+
9
+ // ── Parse helpers ─────────────────────────────────────────────────────────────
10
+
11
+ /** Parse text as a full attribute value (Start rule). */
12
+ export function parseAttr(text: string): ASTNode {
13
+ return parse(text, "Start");
14
+ }
15
+
16
+ /** Parse text as a lock expression. */
17
+ export function parseLock(text: string): ASTNode {
18
+ return parse(text, "LockExpr");
19
+ }
20
+
21
+ /** Assert parse succeeds and return the AST. */
22
+ export function mustParse(text: string): ASTNode {
23
+ return parseAttr(text);
24
+ }
25
+
26
+ /** Assert parse FAILS (used for regression / negative tests). */
27
+ export function mustFail(text: string, ctx = "Start"): ParseError {
28
+ try {
29
+ parse(text, ctx as "Start" | "LockExpr");
30
+ } catch (e) {
31
+ if (e instanceof ParseError) return e;
32
+ throw e;
33
+ }
34
+ throw new Error(`Expected parse of ${JSON.stringify(text)} to fail but it succeeded`);
35
+ }
36
+
37
+ // ── AST query helpers ─────────────────────────────────────────────────────────
38
+
39
+ /** Collect all nodes of a given type anywhere in the tree. */
40
+ export function findAll(node: ASTNode, type: string): ASTNode[] {
41
+ const found: ASTNode[] = [];
42
+ function walk(n: ASTNode) {
43
+ if (!n || typeof n !== "object") return;
44
+ if (n.type === type) found.push(n);
45
+ for (const val of Object.values(n)) {
46
+ if (Array.isArray(val)) val.forEach((v) => { if (v?.type) walk(v); });
47
+ else if (val?.type) walk(val as ASTNode);
48
+ }
49
+ }
50
+ walk(node);
51
+ return found;
52
+ }
53
+
54
+ /** Find the first node of a given type, or throw. */
55
+ export function findFirst(node: ASTNode, type: string): ASTNode {
56
+ const results = findAll(node, type);
57
+ if (results.length === 0) throw new Error(`No node of type "${type}" found`);
58
+ return results[0];
59
+ }
60
+
61
+ /** Collect all Literal values in order (depth-first). */
62
+ export function literals(node: ASTNode): string[] {
63
+ return findAll(node, "Literal").map((n) => n.value as string);
64
+ }
65
+
66
+ /** Collect all Substitution codes in order. */
67
+ export function substitutions(node: ASTNode): string[] {
68
+ return findAll(node, "Substitution").map((n) => n.code as string);
69
+ }