@aperturesyndicate/synx-format 3.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/LICENSE +21 -0
- package/README.md +144 -0
- package/SPECIFICATION.md +6 -0
- package/bin/synx.js +146 -0
- package/dist/browser.d.ts +17 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +72 -0
- package/dist/browser.js.map +1 -0
- package/dist/calc.d.ts +16 -0
- package/dist/calc.d.ts.map +1 -0
- package/dist/calc.js +140 -0
- package/dist/calc.js.map +1 -0
- package/dist/demo-browser.html +153 -0
- package/dist/engine.d.ts +9 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +970 -0
- package/dist/engine.js.map +1 -0
- package/dist/index.d.ts +193 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +810 -0
- package/dist/index.js.map +1 -0
- package/dist/parser.d.ts +12 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +442 -0
- package/dist/parser.js.map +1 -0
- package/dist/synx.browser.js +29 -0
- package/dist/synx.browser.js.map +7 -0
- package/dist/synx.browser.mjs +28 -0
- package/dist/synx.browser.mjs.map +7 -0
- package/dist/types.d.ts +85 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +22 -0
- package/dist/types.js.map +1 -0
- package/package.json +63 -0
package/dist/engine.js
ADDED
|
@@ -0,0 +1,970 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* SYNX Engine — @aperturesyndicate/synx
|
|
4
|
+
*
|
|
5
|
+
* Resolves active markers (:random, :calc, :env, :alias, :secret, etc.)
|
|
6
|
+
* in a parsed SYNX object tree. Only runs in !active mode.
|
|
7
|
+
*/
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.resolve = resolve;
|
|
10
|
+
const calc_1 = require("./calc");
|
|
11
|
+
const parser_1 = require("./parser");
|
|
12
|
+
// Lazy-loaded Node.js modules (not available in browser)
|
|
13
|
+
let fs;
|
|
14
|
+
let pathModule;
|
|
15
|
+
try {
|
|
16
|
+
fs = require('fs');
|
|
17
|
+
pathModule = require('path');
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// Browser environment — :include will not work
|
|
21
|
+
}
|
|
22
|
+
// ─── Security constants ───────────────────────────────────
|
|
23
|
+
const MAX_CALC_EXPR_LEN = 4096;
|
|
24
|
+
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
|
25
|
+
const DEFAULT_MAX_INCLUDE_DEPTH = 16;
|
|
26
|
+
/** Maximum object nesting depth for active-mode resolution (prevents stack overflow). */
|
|
27
|
+
const MAX_RESOLVE_DEPTH = 512;
|
|
28
|
+
/** Ensure a file path stays inside the base directory (path jail). */
|
|
29
|
+
function jailPath(base, filePath) {
|
|
30
|
+
if (!pathModule)
|
|
31
|
+
throw new Error('path module not available');
|
|
32
|
+
// Block absolute paths
|
|
33
|
+
if (pathModule.isAbsolute(filePath)) {
|
|
34
|
+
throw new Error(`absolute path not allowed: ${filePath}`);
|
|
35
|
+
}
|
|
36
|
+
const resolved = pathModule.resolve(base, filePath);
|
|
37
|
+
const normalizedBase = pathModule.resolve(base);
|
|
38
|
+
// Ensure resolved path starts with the base directory
|
|
39
|
+
if (!resolved.startsWith(normalizedBase + pathModule.sep) && resolved !== normalizedBase) {
|
|
40
|
+
throw new Error(`path escapes base directory: ${filePath}`);
|
|
41
|
+
}
|
|
42
|
+
return resolved;
|
|
43
|
+
}
|
|
44
|
+
/** Check that a file does not exceed MAX_FILE_SIZE. */
|
|
45
|
+
function checkFileSize(filePath) {
|
|
46
|
+
if (!fs)
|
|
47
|
+
return;
|
|
48
|
+
const stat = fs.statSync(filePath);
|
|
49
|
+
if (stat.size > MAX_FILE_SIZE) {
|
|
50
|
+
throw new Error(`file too large (${stat.size} bytes, max ${MAX_FILE_SIZE})`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// ─── Secret wrapper ───────────────────────────────────────
|
|
54
|
+
class SynxSecret {
|
|
55
|
+
constructor(value) {
|
|
56
|
+
this._value = value;
|
|
57
|
+
}
|
|
58
|
+
toString() {
|
|
59
|
+
return '[SECRET]';
|
|
60
|
+
}
|
|
61
|
+
toJSON() {
|
|
62
|
+
return '[SECRET]';
|
|
63
|
+
}
|
|
64
|
+
/** Call this explicitly to get the real value */
|
|
65
|
+
reveal() {
|
|
66
|
+
return this._value;
|
|
67
|
+
}
|
|
68
|
+
[Symbol.toPrimitive](hint) {
|
|
69
|
+
if (hint === 'number')
|
|
70
|
+
return NaN;
|
|
71
|
+
return '[SECRET]';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const SPAM_BUCKETS = new Map();
|
|
75
|
+
// ─── Engine ───────────────────────────────────────────────
|
|
76
|
+
function resolve(obj, options = {}, root, includesMap, _resolveDepth = 0, _currentPath = '') {
|
|
77
|
+
if (!root) {
|
|
78
|
+
root = obj;
|
|
79
|
+
// ── :inherit pre-pass (only at root level) ──
|
|
80
|
+
applyInheritance(obj);
|
|
81
|
+
// Remove private blocks (keys starting with _)
|
|
82
|
+
for (const k of Object.keys(obj)) {
|
|
83
|
+
if (k.startsWith('_'))
|
|
84
|
+
delete obj[k];
|
|
85
|
+
}
|
|
86
|
+
// ── Load !include directives ──
|
|
87
|
+
if (!includesMap && options._includes) {
|
|
88
|
+
includesMap = loadIncludes(options._includes, options);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Guard: prevent stack overflow from deeply nested objects
|
|
92
|
+
if (_resolveDepth >= MAX_RESOLVE_DEPTH) {
|
|
93
|
+
for (const k of Object.keys(obj)) {
|
|
94
|
+
if (k !== '__synx') {
|
|
95
|
+
obj[k] = 'NESTING_ERR: maximum object nesting depth exceeded';
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return obj;
|
|
99
|
+
}
|
|
100
|
+
const metaMap = obj.__synx;
|
|
101
|
+
for (const key of Object.keys(obj)) {
|
|
102
|
+
if (key === '__synx')
|
|
103
|
+
continue;
|
|
104
|
+
let value = obj[key];
|
|
105
|
+
// Recurse into nested objects
|
|
106
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
107
|
+
resolve(value, options, root, includesMap, _resolveDepth + 1, _currentPath ? `${_currentPath}.${key}` : key);
|
|
108
|
+
}
|
|
109
|
+
// Recurse into arrays of objects
|
|
110
|
+
if (Array.isArray(value)) {
|
|
111
|
+
for (const item of value) {
|
|
112
|
+
if (item && typeof item === 'object' && !Array.isArray(item)) {
|
|
113
|
+
resolve(item, options, root, includesMap, _resolveDepth + 1, _currentPath ? `${_currentPath}.${key}` : key);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// Apply markers
|
|
118
|
+
if (!metaMap || !metaMap[key])
|
|
119
|
+
continue;
|
|
120
|
+
const { markers, args } = metaMap[key];
|
|
121
|
+
// ── :spam ──
|
|
122
|
+
// Syntax: key:spam:MAX_CALLS:WINDOW_SEC target
|
|
123
|
+
// WINDOW_SEC defaults to 1 when omitted.
|
|
124
|
+
if (markers.includes('spam')) {
|
|
125
|
+
const idx = markers.indexOf('spam');
|
|
126
|
+
const maxCalls = parseInt(markers[idx + 1] ?? '0', 10);
|
|
127
|
+
const windowSec = Math.max(1, parseInt(markers[idx + 2] ?? '1', 10) || 1);
|
|
128
|
+
if (!Number.isFinite(maxCalls) || maxCalls <= 0) {
|
|
129
|
+
obj[key] = 'SPAM_ERR: invalid limit, use :spam:MAX[:WINDOW_SEC]';
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const target = String(obj[key] ?? key);
|
|
133
|
+
const bucketKey = `${key}::${target}`;
|
|
134
|
+
if (!allowSpamAccess(bucketKey, maxCalls, windowSec)) {
|
|
135
|
+
obj[key] = `SPAM_ERR: '${target}' exceeded ${maxCalls} calls per ${windowSec}s`;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const resolvedTarget = deepGet(root, target) ?? deepGet(obj, target);
|
|
139
|
+
if (resolvedTarget !== undefined) {
|
|
140
|
+
obj[key] = resolvedTarget;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// ── :include ──
|
|
144
|
+
if (markers.includes('include') && typeof obj[key] === 'string') {
|
|
145
|
+
if (!fs || !pathModule) {
|
|
146
|
+
obj[key] = 'INCLUDE_ERR: :include is not supported in browser';
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const maxDepth = options.maxIncludeDepth ?? DEFAULT_MAX_INCLUDE_DEPTH;
|
|
150
|
+
const currentDepth = options._includeDepth ?? 0;
|
|
151
|
+
if (currentDepth >= maxDepth) {
|
|
152
|
+
obj[key] = `INCLUDE_ERR: max include depth (${maxDepth}) exceeded`;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const includePath = String(obj[key]);
|
|
156
|
+
const basePath = options.basePath || (typeof process !== 'undefined' ? process.cwd() : '.');
|
|
157
|
+
try {
|
|
158
|
+
const fullPath = jailPath(basePath, includePath);
|
|
159
|
+
checkFileSize(fullPath);
|
|
160
|
+
const text = fs.readFileSync(fullPath, 'utf-8');
|
|
161
|
+
const { root: included, mode: includedMode } = (0, parser_1.parseData)(text);
|
|
162
|
+
if (includedMode === 'active') {
|
|
163
|
+
resolve(included, { ...options, basePath: pathModule.dirname(fullPath), _includeDepth: currentDepth + 1 }, root);
|
|
164
|
+
}
|
|
165
|
+
obj[key] = included;
|
|
166
|
+
}
|
|
167
|
+
catch (e) {
|
|
168
|
+
obj[key] = `INCLUDE_ERR: ${e.message}`;
|
|
169
|
+
}
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
// ── :env ──
|
|
173
|
+
if (markers.includes('env')) {
|
|
174
|
+
const varName = String(value);
|
|
175
|
+
const envSource = options.env || (typeof process !== 'undefined' ? process.env : {});
|
|
176
|
+
const envVal = envSource[varName];
|
|
177
|
+
// Check for :default in the marker chain
|
|
178
|
+
const defaultIdx = markers.indexOf('default');
|
|
179
|
+
// Check if key has (string) type hint — if so, skip auto-detection
|
|
180
|
+
const forceString = metaMap[key]?.typeHint === 'string';
|
|
181
|
+
if (envVal !== undefined && envVal !== '') {
|
|
182
|
+
obj[key] = forceString ? envVal : (isNaN(Number(envVal)) ? envVal : Number(envVal));
|
|
183
|
+
}
|
|
184
|
+
else if (defaultIdx !== -1 && markers.length > defaultIdx + 1) {
|
|
185
|
+
// :env:default:VALUE — join all parts after 'default' back with ':'
|
|
186
|
+
// to preserve IPs (0.0.0.0) and compound values
|
|
187
|
+
const fallback = markers.slice(defaultIdx + 1).join(':');
|
|
188
|
+
obj[key] = forceString ? fallback : (isNaN(Number(fallback)) ? fallback : Number(fallback));
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
obj[key] = null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// ── :random ──
|
|
195
|
+
if (markers.includes('random') && Array.isArray(obj[key])) {
|
|
196
|
+
const arr = obj[key];
|
|
197
|
+
if (arr.length === 0) {
|
|
198
|
+
obj[key] = null;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (args && args.length > 0) {
|
|
202
|
+
// Weighted random
|
|
203
|
+
const weights = args.map(Number);
|
|
204
|
+
obj[key] = weightedRandom(arr, weights);
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
// Equal probability
|
|
208
|
+
obj[key] = arr[Math.floor(Math.random() * arr.length)];
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// ── :ref ──
|
|
212
|
+
// Like :alias but feeds the resolved value into subsequent markers.
|
|
213
|
+
// Supports :ref:calc shorthand: key:ref:calc:*2 base_rate → resolves base_rate, then applies "VALUE * 2".
|
|
214
|
+
if (markers.includes('ref') && typeof obj[key] === 'string') {
|
|
215
|
+
const target = obj[key];
|
|
216
|
+
const resolved = deepGet(root, target) ?? deepGet(obj, target);
|
|
217
|
+
if (resolved !== undefined) {
|
|
218
|
+
obj[key] = resolved;
|
|
219
|
+
// If :calc follows, prepend the resolved value to the calc expression
|
|
220
|
+
if (markers.includes('calc')) {
|
|
221
|
+
const calcIdx = markers.indexOf('calc');
|
|
222
|
+
const calcExpr = markers[calcIdx + 1] ?? '';
|
|
223
|
+
if (calcExpr && typeof resolved === 'number') {
|
|
224
|
+
// Shorthand: :ref:calc:*2 → VALUE * 2, :ref:calc:+10 → VALUE + 10
|
|
225
|
+
const first = calcExpr.charAt(0);
|
|
226
|
+
if ('+-*/%'.includes(first)) {
|
|
227
|
+
const fullExpr = `${resolved} ${calcExpr}`;
|
|
228
|
+
try {
|
|
229
|
+
obj[key] = (0, calc_1.safeCalc)(fullExpr);
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
obj[key] = resolved;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
obj[key] = null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// ── :i18n ──
|
|
243
|
+
// Selects a localized value from a nested object based on options.lang.
|
|
244
|
+
// Syntax: name:i18n\n en Plains\n ru Равнины
|
|
245
|
+
if (markers.includes('i18n') && obj[key] && typeof obj[key] === 'object' && !Array.isArray(obj[key])) {
|
|
246
|
+
const translations = obj[key];
|
|
247
|
+
const lang = options.lang || 'en';
|
|
248
|
+
const val = translations[lang] ?? translations['en'] ?? Object.values(translations)[0] ?? null;
|
|
249
|
+
obj[key] = val;
|
|
250
|
+
}
|
|
251
|
+
// ── :calc ──
|
|
252
|
+
if (markers.includes('calc') && typeof obj[key] === 'string') {
|
|
253
|
+
let expr = obj[key];
|
|
254
|
+
if (expr.length > MAX_CALC_EXPR_LEN) {
|
|
255
|
+
obj[key] = `CALC_ERR: expression too long (${expr.length} chars, max ${MAX_CALC_EXPR_LEN})`;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
// Collect already-resolved numeric variables from root + local scope.
|
|
259
|
+
// Keys that appear later in iteration order and still hold a marker
|
|
260
|
+
// value (e.g. an unresolved :env string) are not yet numbers and will
|
|
261
|
+
// be absent from vars — place :calc keys after their dependencies.
|
|
262
|
+
const vars = new Map();
|
|
263
|
+
for (const rKey of Object.keys(root)) {
|
|
264
|
+
if (typeof root[rKey] === 'number')
|
|
265
|
+
vars.set(rKey, String(root[rKey]));
|
|
266
|
+
}
|
|
267
|
+
for (const rKey of Object.keys(obj)) {
|
|
268
|
+
if (rKey !== key && typeof obj[rKey] === 'number')
|
|
269
|
+
vars.set(rKey, String(obj[rKey]));
|
|
270
|
+
}
|
|
271
|
+
// Substitute whole-word occurrences without building RegExp objects
|
|
272
|
+
if (vars.size > 0)
|
|
273
|
+
expr = replaceVars(expr, vars);
|
|
274
|
+
try {
|
|
275
|
+
obj[key] = (0, calc_1.safeCalc)(expr);
|
|
276
|
+
}
|
|
277
|
+
catch (e) {
|
|
278
|
+
obj[key] = `CALC_ERR: ${e.message}`;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
// ── :alias ──
|
|
282
|
+
if (markers.includes('alias') && typeof obj[key] === 'string') {
|
|
283
|
+
const target = obj[key];
|
|
284
|
+
// Detect direct self-reference (bare key or full dot-path)
|
|
285
|
+
const currentKeyPath = _currentPath ? `${_currentPath}.${key}` : key;
|
|
286
|
+
if (target === key || target === currentKeyPath) {
|
|
287
|
+
obj[key] = `ALIAS_ERR: self-referential alias: ${currentKeyPath} → ${target}`;
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
// Detect one-hop cycle: a → b → a
|
|
291
|
+
// Only flag as cycle if the target key ALSO has an :alias marker.
|
|
292
|
+
// Without this check, plain string values that happen to match the current
|
|
293
|
+
// key name would produce false-positive ALIAS_ERR results.
|
|
294
|
+
const targetVal = deepGet(root, target);
|
|
295
|
+
// Check if the target key has an :alias marker in metadata.
|
|
296
|
+
// Must look up the target's PARENT object's __synx, not the root's,
|
|
297
|
+
// to correctly handle nested keys like "section.foo".
|
|
298
|
+
const lastDot = target.lastIndexOf('.');
|
|
299
|
+
const targetParentPath = lastDot >= 0 ? target.slice(0, lastDot) : '';
|
|
300
|
+
const targetLeafKey = lastDot >= 0 ? target.slice(lastDot + 1) : target;
|
|
301
|
+
const targetParentObj = targetParentPath ? deepGet(root, targetParentPath) : root;
|
|
302
|
+
const targetParentMeta = (targetParentObj != null && typeof targetParentObj === 'object')
|
|
303
|
+
? targetParentObj.__synx
|
|
304
|
+
: undefined;
|
|
305
|
+
const targetHasAlias = targetParentMeta?.[targetLeafKey]?.markers?.includes('alias') ?? false;
|
|
306
|
+
const isCycle = targetHasAlias && typeof targetVal === 'string' && targetVal === key;
|
|
307
|
+
if (isCycle) {
|
|
308
|
+
obj[key] = `ALIAS_ERR: circular alias detected: ${key} → ${target}`;
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
obj[key] = targetVal ?? null;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
// ── :secret ──
|
|
316
|
+
if (markers.includes('secret')) {
|
|
317
|
+
obj[key] = new SynxSecret(String(obj[key]));
|
|
318
|
+
}
|
|
319
|
+
// ── :unique ──
|
|
320
|
+
if (markers.includes('unique') && Array.isArray(obj[key])) {
|
|
321
|
+
const seen = new Set();
|
|
322
|
+
obj[key] = obj[key].filter((item) => {
|
|
323
|
+
const s = String(item);
|
|
324
|
+
if (seen.has(s))
|
|
325
|
+
return false;
|
|
326
|
+
seen.add(s);
|
|
327
|
+
return true;
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
// ── :geo ──
|
|
331
|
+
if (markers.includes('geo') && Array.isArray(obj[key])) {
|
|
332
|
+
const region = options.region || 'US';
|
|
333
|
+
const arr = obj[key];
|
|
334
|
+
const found = arr.find((item) => String(item).startsWith(region + ' '));
|
|
335
|
+
if (found) {
|
|
336
|
+
obj[key] = found.substring(region.length + 1).trim();
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
// Fallback to first entry
|
|
340
|
+
const first = arr[0];
|
|
341
|
+
if (typeof first === 'string' && first.includes(' ')) {
|
|
342
|
+
obj[key] = first.substring(first.indexOf(' ') + 1).trim();
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
obj[key] = first ?? null;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
// ── :template (legacy — handled by auto-{} below) ──
|
|
350
|
+
// ── :split ──
|
|
351
|
+
if (markers.includes('split') && typeof obj[key] === 'string') {
|
|
352
|
+
const splitIdx = markers.indexOf('split');
|
|
353
|
+
const delimArg = (splitIdx + 1 < markers.length) ? markers[splitIdx + 1] : ',';
|
|
354
|
+
const sep = delimiterFromKeyword(delimArg);
|
|
355
|
+
obj[key] = obj[key].split(sep).map(s => s.trim()).filter(s => s !== '').map(s => castPrimitive(s));
|
|
356
|
+
}
|
|
357
|
+
// ── :join ──
|
|
358
|
+
if (markers.includes('join') && Array.isArray(obj[key])) {
|
|
359
|
+
const joinIdx = markers.indexOf('join');
|
|
360
|
+
const delimArg = (joinIdx + 1 < markers.length) ? markers[joinIdx + 1] : ',';
|
|
361
|
+
const sep = delimiterFromKeyword(delimArg);
|
|
362
|
+
obj[key] = obj[key].map(v => String(v)).join(sep);
|
|
363
|
+
}
|
|
364
|
+
// ── :default (standalone, not combined with :env) ──
|
|
365
|
+
if (markers.includes('default') && !markers.includes('env')) {
|
|
366
|
+
if (obj[key] === null || obj[key] === undefined || obj[key] === '') {
|
|
367
|
+
const defaultIdx = markers.indexOf('default');
|
|
368
|
+
if (defaultIdx !== -1 && markers.length > defaultIdx + 1) {
|
|
369
|
+
const fallback = markers.slice(defaultIdx + 1).join(':');
|
|
370
|
+
const forceStr = metaMap[key]?.typeHint === 'string';
|
|
371
|
+
obj[key] = forceStr ? fallback : (isNaN(Number(fallback)) ? fallback : Number(fallback));
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
// ── :clamp ──
|
|
376
|
+
// Syntax: key:clamp:MIN:MAX value
|
|
377
|
+
if (markers.includes('clamp')) {
|
|
378
|
+
const idx = markers.indexOf('clamp');
|
|
379
|
+
const lo = parseFloat(markers[idx + 1] ?? '');
|
|
380
|
+
const hi = parseFloat(markers[idx + 2] ?? '');
|
|
381
|
+
if (!isNaN(lo) && !isNaN(hi)) {
|
|
382
|
+
if (lo > hi) {
|
|
383
|
+
obj[key] = `CONSTRAINT_ERR: clamp min (${lo}) > max (${hi})`;
|
|
384
|
+
}
|
|
385
|
+
else if (typeof obj[key] === 'number') {
|
|
386
|
+
obj[key] = Math.min(hi, Math.max(lo, obj[key]));
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
// ── :round ──
|
|
391
|
+
// Syntax: key:round:N value (N = decimal places, default 0)
|
|
392
|
+
if (markers.includes('round')) {
|
|
393
|
+
const idx = markers.indexOf('round');
|
|
394
|
+
const decimals = parseInt(markers[idx + 1] ?? '0', 10) || 0;
|
|
395
|
+
if (typeof obj[key] === 'number') {
|
|
396
|
+
const factor = Math.pow(10, decimals);
|
|
397
|
+
obj[key] = Math.round(obj[key] * factor) / factor;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
// ── :map ──
|
|
401
|
+
// Syntax: key:map:source_key\n - lookup_val result_text
|
|
402
|
+
if (markers.includes('map') && Array.isArray(obj[key])) {
|
|
403
|
+
const idx = markers.indexOf('map');
|
|
404
|
+
const sourceKey = markers[idx + 1] ?? '';
|
|
405
|
+
const lookupVal = String(sourceKey ? (deepGet(root, sourceKey) ?? deepGet(obj, sourceKey) ?? '') : '');
|
|
406
|
+
const arr = obj[key];
|
|
407
|
+
const found = arr.find((item) => {
|
|
408
|
+
const s = String(item);
|
|
409
|
+
const sep = s.indexOf(' ');
|
|
410
|
+
return sep !== -1 && s.substring(0, sep).trim() === lookupVal;
|
|
411
|
+
});
|
|
412
|
+
obj[key] = found
|
|
413
|
+
? castPrimitive(found.substring(found.indexOf(' ') + 1).trim())
|
|
414
|
+
: null;
|
|
415
|
+
}
|
|
416
|
+
// ── :format ──
|
|
417
|
+
// Syntax: key:format:PATTERN value (e.g. %.2f, %05d)
|
|
418
|
+
if (markers.includes('format')) {
|
|
419
|
+
const idx = markers.indexOf('format');
|
|
420
|
+
const pattern = markers[idx + 1] ?? '%s';
|
|
421
|
+
obj[key] = applyFormatPattern(pattern, obj[key]);
|
|
422
|
+
}
|
|
423
|
+
// ── :fallback ──
|
|
424
|
+
// Syntax: key:fallback:DEFAULT_PATH value
|
|
425
|
+
// If value is empty OR file does not exist, use the fallback.
|
|
426
|
+
if (markers.includes('fallback')) {
|
|
427
|
+
const idx = markers.indexOf('fallback');
|
|
428
|
+
const defaultVal = markers[idx + 1] ?? '';
|
|
429
|
+
const current = obj[key];
|
|
430
|
+
let useFallback = current === null || current === undefined || current === '';
|
|
431
|
+
if (!useFallback && typeof current === 'string' && fs && pathModule && options.basePath) {
|
|
432
|
+
try {
|
|
433
|
+
const fullPath = jailPath(options.basePath, current);
|
|
434
|
+
useFallback = !fs.existsSync(fullPath);
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
useFallback = true; // path escapes jail → treat as missing → use fallback
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
if (useFallback && defaultVal) {
|
|
441
|
+
obj[key] = defaultVal;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
// ── :once ──
|
|
445
|
+
// Syntax: key:once or key:once:uuid or key:once:random or key:once:timestamp
|
|
446
|
+
// Generates a value once and persists it in a .synx.lock file.
|
|
447
|
+
if (markers.includes('once')) {
|
|
448
|
+
const idx = markers.indexOf('once');
|
|
449
|
+
const genType = markers[idx + 1] ?? 'uuid';
|
|
450
|
+
const lockPath = pathModule && options.basePath
|
|
451
|
+
? pathModule.resolve(options.basePath, '.synx.lock')
|
|
452
|
+
: '.synx.lock';
|
|
453
|
+
const existing = readLockValue(lockPath, key);
|
|
454
|
+
if (existing !== null) {
|
|
455
|
+
obj[key] = existing;
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
let generated;
|
|
459
|
+
if (genType === 'uuid') {
|
|
460
|
+
generated = generateUuid();
|
|
461
|
+
}
|
|
462
|
+
else if (genType === 'timestamp') {
|
|
463
|
+
generated = String(Date.now());
|
|
464
|
+
}
|
|
465
|
+
else if (genType === 'random') {
|
|
466
|
+
generated = String(Math.floor(Math.random() * 2147483647));
|
|
467
|
+
}
|
|
468
|
+
else {
|
|
469
|
+
generated = generateUuid();
|
|
470
|
+
}
|
|
471
|
+
writeLockValue(lockPath, key, generated);
|
|
472
|
+
obj[key] = generated;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
// ── :version ──
|
|
476
|
+
// Syntax: key:version:OP:REQUIRED value
|
|
477
|
+
// Compares current version string against required, returns bool.
|
|
478
|
+
if (markers.includes('version') && typeof obj[key] === 'string') {
|
|
479
|
+
const idx = markers.indexOf('version');
|
|
480
|
+
const op = markers[idx + 1] ?? '>=';
|
|
481
|
+
const required = markers[idx + 2] ?? '0';
|
|
482
|
+
obj[key] = compareVersions(obj[key], op, required);
|
|
483
|
+
}
|
|
484
|
+
// ── :watch ──
|
|
485
|
+
// Syntax: key:watch:KEY_PATH ./file (reads at parse time)
|
|
486
|
+
if (markers.includes('watch') && typeof obj[key] === 'string') {
|
|
487
|
+
if (!fs || !pathModule) {
|
|
488
|
+
obj[key] = 'WATCH_ERR: not supported in browser';
|
|
489
|
+
}
|
|
490
|
+
else {
|
|
491
|
+
const maxDepth = options.maxIncludeDepth ?? DEFAULT_MAX_INCLUDE_DEPTH;
|
|
492
|
+
const currentDepth = options._includeDepth ?? 0;
|
|
493
|
+
if (currentDepth >= maxDepth) {
|
|
494
|
+
obj[key] = `WATCH_ERR: max include depth (${maxDepth}) exceeded`;
|
|
495
|
+
}
|
|
496
|
+
else {
|
|
497
|
+
const filePath = obj[key];
|
|
498
|
+
const basePath = options.basePath || (typeof process !== 'undefined' ? process.cwd() : '.');
|
|
499
|
+
const idx = markers.indexOf('watch');
|
|
500
|
+
const keyPath = markers[idx + 1];
|
|
501
|
+
try {
|
|
502
|
+
const fullPath = jailPath(basePath, filePath);
|
|
503
|
+
checkFileSize(fullPath);
|
|
504
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
505
|
+
const ext = pathModule.extname(fullPath).slice(1);
|
|
506
|
+
if (keyPath) {
|
|
507
|
+
obj[key] = extractFromFileContent(content, keyPath, ext);
|
|
508
|
+
}
|
|
509
|
+
else {
|
|
510
|
+
obj[key] = content.trim();
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
catch (e) {
|
|
514
|
+
obj[key] = `WATCH_ERR: ${e.message}`;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
// ── :prompt ──
|
|
520
|
+
// Converts a subtree to a SYNX-formatted string wrapped in a labeled code fence.
|
|
521
|
+
if (markers.includes('prompt')) {
|
|
522
|
+
const idx = markers.indexOf('prompt');
|
|
523
|
+
const label = markers[idx + 1] ?? key;
|
|
524
|
+
const val = obj[key];
|
|
525
|
+
const synxText = stringifyValue(val, 0);
|
|
526
|
+
obj[key] = `${label} (SYNX):\n\`\`\`synx\n${synxText}\`\`\``;
|
|
527
|
+
}
|
|
528
|
+
// ── :vision ──
|
|
529
|
+
// Metadata-only marker. Recognized (no error), value passes through.
|
|
530
|
+
// ── :audio ──
|
|
531
|
+
// Metadata-only marker. Recognized (no error), value passes through.
|
|
532
|
+
// ── Constraint validation (always last, after all markers resolved) ──
|
|
533
|
+
if (metaMap && metaMap[key]?.constraints) {
|
|
534
|
+
validateConstraints(obj, key, metaMap[key].constraints);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
// ── Auto-{} interpolation (separate pass, runs on ALL string values) ──
|
|
538
|
+
for (const key of Object.keys(obj)) {
|
|
539
|
+
if (key === '__synx')
|
|
540
|
+
continue;
|
|
541
|
+
if (typeof obj[key] === 'string' && obj[key].includes('{')) {
|
|
542
|
+
obj[key] = resolveInterpolation(obj[key], root, obj, includesMap);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return obj;
|
|
546
|
+
}
|
|
547
|
+
// ─── Inheritance pre-pass ─────────────────────────────────
|
|
548
|
+
function applyInheritance(obj) {
|
|
549
|
+
const metaMap = obj.__synx;
|
|
550
|
+
if (!metaMap)
|
|
551
|
+
return;
|
|
552
|
+
for (const key of Object.keys(obj)) {
|
|
553
|
+
if (key === '__synx')
|
|
554
|
+
continue;
|
|
555
|
+
const meta = metaMap[key];
|
|
556
|
+
if (!meta || !meta.markers.includes('inherit'))
|
|
557
|
+
continue;
|
|
558
|
+
const idx = meta.markers.indexOf('inherit');
|
|
559
|
+
const parentName = meta.markers[idx + 1];
|
|
560
|
+
if (!parentName)
|
|
561
|
+
continue;
|
|
562
|
+
const parentObj = obj[parentName];
|
|
563
|
+
if (!parentObj || typeof parentObj !== 'object' || Array.isArray(parentObj))
|
|
564
|
+
continue;
|
|
565
|
+
const childObj = obj[key];
|
|
566
|
+
if (!childObj || typeof childObj !== 'object' || Array.isArray(childObj))
|
|
567
|
+
continue;
|
|
568
|
+
// Merge: parent fields first, child fields override
|
|
569
|
+
const merged = { ...parentObj, ...childObj };
|
|
570
|
+
// Copy over __synx metadata from both parent and child
|
|
571
|
+
const parentMeta = parentObj.__synx;
|
|
572
|
+
const childMeta = childObj.__synx;
|
|
573
|
+
if (parentMeta || childMeta) {
|
|
574
|
+
const mergedMeta = { ...(parentMeta || {}), ...(childMeta || {}) };
|
|
575
|
+
Object.defineProperty(merged, '__synx', {
|
|
576
|
+
value: mergedMeta,
|
|
577
|
+
enumerable: false,
|
|
578
|
+
writable: true,
|
|
579
|
+
configurable: true,
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
obj[key] = merged;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
// ─── Auto-{} interpolation ───────────────────────────────
|
|
586
|
+
/**
|
|
587
|
+
* Resolve {key}, {key.nested}, {key:alias}, {key:include} placeholders.
|
|
588
|
+
* - {key} — look up in root, then local scope
|
|
589
|
+
* - {key:alias} — look up in included file with that alias
|
|
590
|
+
* - {key:include} — look up in the first (only) included file
|
|
591
|
+
*/
|
|
592
|
+
function resolveInterpolation(tpl, root, local, includesMap) {
|
|
593
|
+
return tpl.replace(/\{(\w+(?:\.\w+)*)(?::(\w+(?:[./\\][\w./\\]*)?))?\}/g, (_match, ref, scope) => {
|
|
594
|
+
if (scope) {
|
|
595
|
+
// {key:alias} or {key:include}
|
|
596
|
+
if (scope === 'include') {
|
|
597
|
+
if (!includesMap || includesMap.size === 0)
|
|
598
|
+
return _match;
|
|
599
|
+
if (includesMap.size > 1)
|
|
600
|
+
return 'INCLUDE_ERR: multiple !include — specify alias';
|
|
601
|
+
const firstInclude = includesMap.values().next().value;
|
|
602
|
+
const val = deepGet(firstInclude, ref);
|
|
603
|
+
return val != null ? String(val) : _match;
|
|
604
|
+
}
|
|
605
|
+
// Look up by alias
|
|
606
|
+
if (includesMap) {
|
|
607
|
+
const incl = includesMap.get(scope);
|
|
608
|
+
if (incl) {
|
|
609
|
+
const val = deepGet(incl, ref);
|
|
610
|
+
return val != null ? String(val) : _match;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
return _match;
|
|
614
|
+
}
|
|
615
|
+
// {key} — local file
|
|
616
|
+
const val = deepGet(root, ref) ?? deepGet(local, ref);
|
|
617
|
+
return val != null ? String(val) : _match;
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
// ─── !include loader ──────────────────────────────────────
|
|
621
|
+
function loadIncludes(includes, options) {
|
|
622
|
+
const map = new Map();
|
|
623
|
+
if (!fs || !pathModule)
|
|
624
|
+
return map;
|
|
625
|
+
const basePath = options.basePath || (typeof process !== 'undefined' ? process.cwd() : '.');
|
|
626
|
+
const maxDepth = options.maxIncludeDepth ?? DEFAULT_MAX_INCLUDE_DEPTH;
|
|
627
|
+
const currentDepth = options._includeDepth ?? 0;
|
|
628
|
+
if (currentDepth >= maxDepth)
|
|
629
|
+
return map;
|
|
630
|
+
for (const inc of includes) {
|
|
631
|
+
try {
|
|
632
|
+
const fullPath = jailPath(basePath, inc.path);
|
|
633
|
+
checkFileSize(fullPath);
|
|
634
|
+
const text = fs.readFileSync(fullPath, 'utf-8');
|
|
635
|
+
const { root, mode } = (0, parser_1.parseData)(text);
|
|
636
|
+
if (mode === 'active') {
|
|
637
|
+
resolve(root, { ...options, basePath: pathModule.dirname(fullPath), _includeDepth: currentDepth + 1 }, undefined, map);
|
|
638
|
+
}
|
|
639
|
+
map.set(inc.alias, root);
|
|
640
|
+
}
|
|
641
|
+
catch (e) {
|
|
642
|
+
// Include loading failed — skip silently
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
return map;
|
|
646
|
+
}
|
|
647
|
+
// ─── Constraint enforcement ───────────────────────────────────────────────
|
|
648
|
+
function validateConstraints(obj, key, c) {
|
|
649
|
+
const val = obj[key];
|
|
650
|
+
// required
|
|
651
|
+
if (c.required && (val === null || val === undefined || val === '')) {
|
|
652
|
+
obj[key] = `CONSTRAINT_ERR: '${key}' is required`;
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
if (val === null || val === undefined)
|
|
656
|
+
return;
|
|
657
|
+
// type check
|
|
658
|
+
if (c.type) {
|
|
659
|
+
const ok = (() => {
|
|
660
|
+
switch (c.type) {
|
|
661
|
+
case 'int': return typeof val === 'number' && Number.isInteger(val);
|
|
662
|
+
case 'float': return typeof val === 'number';
|
|
663
|
+
case 'bool': return typeof val === 'boolean';
|
|
664
|
+
case 'string': return typeof val === 'string';
|
|
665
|
+
default: return true;
|
|
666
|
+
}
|
|
667
|
+
})();
|
|
668
|
+
if (!ok) {
|
|
669
|
+
obj[key] = `CONSTRAINT_ERR: '${key}' expected type '${c.type}'`;
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
// enum check
|
|
674
|
+
if (c.enum) {
|
|
675
|
+
const strVal = String(val);
|
|
676
|
+
if (!c.enum.includes(strVal)) {
|
|
677
|
+
obj[key] = `CONSTRAINT_ERR: '${key}' must be one of [${c.enum.join('|')}]`;
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
// min / max (numbers: value range; strings: length range)
|
|
682
|
+
const n = typeof val === 'number' ? val
|
|
683
|
+
: typeof val === 'string' && (c.min !== undefined || c.max !== undefined)
|
|
684
|
+
? val.length : null;
|
|
685
|
+
if (n !== null) {
|
|
686
|
+
if (c.min !== undefined && n < c.min) {
|
|
687
|
+
obj[key] = `CONSTRAINT_ERR: '${key}' value ${n} is below min ${c.min}`;
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
if (c.max !== undefined && n > c.max) {
|
|
691
|
+
obj[key] = `CONSTRAINT_ERR: '${key}' value ${n} exceeds max ${c.max}`;
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
// pattern (regex match — reject pathological patterns to prevent ReDoS)
|
|
696
|
+
if (c.pattern && typeof val === 'string') {
|
|
697
|
+
if (c.pattern.length > 128)
|
|
698
|
+
return;
|
|
699
|
+
try {
|
|
700
|
+
if (!new RegExp(c.pattern).test(val)) {
|
|
701
|
+
obj[key] = `CONSTRAINT_ERR: '${key}' does not match pattern /${c.pattern}/`;
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
catch { /* invalid regex — skip silently */ }
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
// ─── New-marker helpers ───────────────────────────────────
|
|
709
|
+
function applyFormatPattern(pattern, value) {
|
|
710
|
+
const n = typeof value === 'number' ? value : parseFloat(String(value));
|
|
711
|
+
if (isNaN(n))
|
|
712
|
+
return String(value);
|
|
713
|
+
// %.2f → fixed decimals
|
|
714
|
+
const floatMatch = pattern.match(/^%\.(\d+)f$/);
|
|
715
|
+
if (floatMatch)
|
|
716
|
+
return n.toFixed(parseInt(floatMatch[1], 10));
|
|
717
|
+
// %05d → zero-padded integer
|
|
718
|
+
const intMatch = pattern.match(/^%0(\d+)d$/);
|
|
719
|
+
if (intMatch)
|
|
720
|
+
return String(Math.round(n)).padStart(parseInt(intMatch[1], 10), '0');
|
|
721
|
+
// %5d → right-padded integer
|
|
722
|
+
const widthMatch = pattern.match(/^%(\d+)d$/);
|
|
723
|
+
if (widthMatch)
|
|
724
|
+
return String(Math.round(n)).padStart(parseInt(widthMatch[1], 10));
|
|
725
|
+
// %e → exponential
|
|
726
|
+
if (pattern === '%e')
|
|
727
|
+
return n.toExponential();
|
|
728
|
+
return String(value);
|
|
729
|
+
}
|
|
730
|
+
function readLockValue(lockPath, key) {
|
|
731
|
+
if (!fs)
|
|
732
|
+
return null;
|
|
733
|
+
try {
|
|
734
|
+
const content = fs.readFileSync(lockPath, 'utf-8');
|
|
735
|
+
for (const line of content.split(/\r?\n/)) {
|
|
736
|
+
if (line.startsWith(key + ' ')) {
|
|
737
|
+
return line.substring(key.length + 1);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
catch { /* file doesn't exist yet */ }
|
|
742
|
+
return null;
|
|
743
|
+
}
|
|
744
|
+
function writeLockValue(lockPath, key, value) {
|
|
745
|
+
if (!fs)
|
|
746
|
+
return;
|
|
747
|
+
let lines = [];
|
|
748
|
+
try {
|
|
749
|
+
lines = fs.readFileSync(lockPath, 'utf-8').split(/\r?\n/);
|
|
750
|
+
}
|
|
751
|
+
catch { /* ok */ }
|
|
752
|
+
const newLine = `${key} ${value}`;
|
|
753
|
+
const idx = lines.findIndex((l) => l.startsWith(key + ' '));
|
|
754
|
+
if (idx !== -1) {
|
|
755
|
+
lines[idx] = newLine;
|
|
756
|
+
}
|
|
757
|
+
else {
|
|
758
|
+
lines.push(newLine);
|
|
759
|
+
}
|
|
760
|
+
try {
|
|
761
|
+
fs.writeFileSync(lockPath, lines.filter(Boolean).join('\n') + '\n', 'utf-8');
|
|
762
|
+
}
|
|
763
|
+
catch { /* ok */ }
|
|
764
|
+
}
|
|
765
|
+
function generateUuid() {
|
|
766
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
767
|
+
return crypto.randomUUID();
|
|
768
|
+
}
|
|
769
|
+
// Fallback manual generation
|
|
770
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
771
|
+
const r = Math.random() * 16 | 0;
|
|
772
|
+
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
function compareVersions(current, op, required) {
|
|
776
|
+
const parseVer = (s) => s.split('.').map((p) => parseInt(p, 10) || 0);
|
|
777
|
+
const cv = parseVer(current);
|
|
778
|
+
const rv = parseVer(required);
|
|
779
|
+
const len = Math.max(cv.length, rv.length);
|
|
780
|
+
let cmp = 0;
|
|
781
|
+
for (let i = 0; i < len; i++) {
|
|
782
|
+
const a = cv[i] ?? 0;
|
|
783
|
+
const b = rv[i] ?? 0;
|
|
784
|
+
if (a !== b) {
|
|
785
|
+
cmp = a > b ? 1 : -1;
|
|
786
|
+
break;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
switch (op) {
|
|
790
|
+
case '>=': return cmp >= 0;
|
|
791
|
+
case '<=': return cmp <= 0;
|
|
792
|
+
case '>': return cmp > 0;
|
|
793
|
+
case '<': return cmp < 0;
|
|
794
|
+
case '==':
|
|
795
|
+
case '=': return cmp === 0;
|
|
796
|
+
case '!=': return cmp !== 0;
|
|
797
|
+
default: return false;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
function extractFromFileContent(content, keyPath, ext) {
|
|
801
|
+
if (ext === 'json') {
|
|
802
|
+
try {
|
|
803
|
+
const obj = JSON.parse(content);
|
|
804
|
+
return keyPath.split('.').reduce((o, k) => o?.[k], obj) ?? null;
|
|
805
|
+
}
|
|
806
|
+
catch {
|
|
807
|
+
return null;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
// SYNX key lookup — parse the file and do a deep-get by dot-path
|
|
811
|
+
try {
|
|
812
|
+
const { root: parsed } = (0, parser_1.parseData)(content);
|
|
813
|
+
return deepGet(parsed, keyPath) ?? null;
|
|
814
|
+
}
|
|
815
|
+
catch {
|
|
816
|
+
return null;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
// ─── Helpers ──────────────────────────────────────────────
|
|
820
|
+
/** Serialize a value to SYNX format string (for :prompt marker). */
|
|
821
|
+
function stringifyValue(value, indent) {
|
|
822
|
+
const sp = ' '.repeat(indent);
|
|
823
|
+
if (value === null || value === undefined)
|
|
824
|
+
return `${sp}null\n`;
|
|
825
|
+
if (typeof value === 'boolean' || typeof value === 'number')
|
|
826
|
+
return `${sp}${value}\n`;
|
|
827
|
+
if (typeof value === 'string')
|
|
828
|
+
return `${sp}${value}\n`;
|
|
829
|
+
if (Array.isArray(value)) {
|
|
830
|
+
let out = '';
|
|
831
|
+
for (const item of value)
|
|
832
|
+
out += `${sp} - ${String(item)}\n`;
|
|
833
|
+
return out;
|
|
834
|
+
}
|
|
835
|
+
if (typeof value === 'object') {
|
|
836
|
+
let out = '';
|
|
837
|
+
for (const [k, v] of Object.entries(value)) {
|
|
838
|
+
if (k === '__synx')
|
|
839
|
+
continue;
|
|
840
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
|
841
|
+
out += `${sp}${k}\n`;
|
|
842
|
+
out += stringifyValue(v, indent + 2);
|
|
843
|
+
}
|
|
844
|
+
else if (Array.isArray(v)) {
|
|
845
|
+
out += `${sp}${k}\n`;
|
|
846
|
+
for (const item of v)
|
|
847
|
+
out += `${sp} - ${String(item)}\n`;
|
|
848
|
+
}
|
|
849
|
+
else {
|
|
850
|
+
out += `${sp}${k} ${v ?? 'null'}\n`;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
return out;
|
|
854
|
+
}
|
|
855
|
+
return `${sp}${String(value)}\n`;
|
|
856
|
+
}
|
|
857
|
+
function castPrimitive(val) {
|
|
858
|
+
if (val === 'true')
|
|
859
|
+
return true;
|
|
860
|
+
if (val === 'false')
|
|
861
|
+
return false;
|
|
862
|
+
if (val === 'null')
|
|
863
|
+
return null;
|
|
864
|
+
if (/^-?\d+$/.test(val))
|
|
865
|
+
return parseInt(val, 10);
|
|
866
|
+
if (/^-?\d+\.\d+$/.test(val))
|
|
867
|
+
return parseFloat(val);
|
|
868
|
+
return val;
|
|
869
|
+
}
|
|
870
|
+
const DELIM_MAP = {
|
|
871
|
+
space: ' ', pipe: '|', dash: '-', dot: '.', semi: ';', tab: '\t', slash: '/',
|
|
872
|
+
};
|
|
873
|
+
function delimiterFromKeyword(keyword) {
|
|
874
|
+
return DELIM_MAP[keyword] || keyword;
|
|
875
|
+
}
|
|
876
|
+
function weightedRandom(items, weights) {
|
|
877
|
+
const w = [...weights];
|
|
878
|
+
if (w.length < items.length) {
|
|
879
|
+
const assigned = w.reduce((a, b) => a + b, 0);
|
|
880
|
+
// When assigned < 100: distribute remaining budget equally.
|
|
881
|
+
// When assigned >= 100: give each unassigned item the same average weight
|
|
882
|
+
// as the assigned ones so they remain reachable.
|
|
883
|
+
const perItem = assigned < 100
|
|
884
|
+
? (100 - assigned) / (items.length - w.length)
|
|
885
|
+
: assigned / w.length;
|
|
886
|
+
while (w.length < items.length)
|
|
887
|
+
w.push(perItem);
|
|
888
|
+
}
|
|
889
|
+
const total = w.reduce((a, b) => a + b, 0);
|
|
890
|
+
if (total <= 0)
|
|
891
|
+
return items[Math.floor(Math.random() * items.length)];
|
|
892
|
+
const rand = Math.random();
|
|
893
|
+
let cumulative = 0;
|
|
894
|
+
for (let i = 0; i < items.length; i++) {
|
|
895
|
+
cumulative += w[i] / total;
|
|
896
|
+
if (rand <= cumulative)
|
|
897
|
+
return items[i];
|
|
898
|
+
}
|
|
899
|
+
return items[items.length - 1];
|
|
900
|
+
}
|
|
901
|
+
// ─── Word-boundary variable substitution (replaces per-key RegExp creation) ─
|
|
902
|
+
function isWordChar(code) {
|
|
903
|
+
return (code >= 48 && code <= 57) // 0-9
|
|
904
|
+
|| (code >= 65 && code <= 90) // A-Z
|
|
905
|
+
|| (code >= 97 && code <= 122) // a-z
|
|
906
|
+
|| code === 95; // _
|
|
907
|
+
}
|
|
908
|
+
function replaceWord(haystack, word, replacement) {
|
|
909
|
+
const wLen = word.length;
|
|
910
|
+
const hLen = haystack.length;
|
|
911
|
+
let result = '';
|
|
912
|
+
let i = 0;
|
|
913
|
+
while (i <= hLen - wLen) {
|
|
914
|
+
if (haystack.slice(i, i + wLen) === word) {
|
|
915
|
+
const before = i === 0 || !isWordChar(haystack.charCodeAt(i - 1));
|
|
916
|
+
const after = i + wLen >= hLen || !isWordChar(haystack.charCodeAt(i + wLen));
|
|
917
|
+
if (before && after) {
|
|
918
|
+
result += replacement;
|
|
919
|
+
i += wLen;
|
|
920
|
+
continue;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
result += haystack[i++];
|
|
924
|
+
}
|
|
925
|
+
while (i < hLen)
|
|
926
|
+
result += haystack[i++];
|
|
927
|
+
return result;
|
|
928
|
+
}
|
|
929
|
+
/** Substitute all variable references in `expr` without creating RegExp objects. */
|
|
930
|
+
function replaceVars(expr, vars) {
|
|
931
|
+
// Process longer keys first to avoid partial-match issues (e.g. "hp" vs "base_hp")
|
|
932
|
+
const sorted = [...vars.entries()].sort((a, b) => b[0].length - a[0].length);
|
|
933
|
+
let result = expr;
|
|
934
|
+
for (const [k, v] of sorted)
|
|
935
|
+
result = replaceWord(result, k, v);
|
|
936
|
+
return result;
|
|
937
|
+
}
|
|
938
|
+
function allowSpamAccess(bucketKey, maxCalls, windowSec) {
|
|
939
|
+
const now = Date.now();
|
|
940
|
+
const windowMs = windowSec * 1000;
|
|
941
|
+
const calls = SPAM_BUCKETS.get(bucketKey) ?? [];
|
|
942
|
+
const filtered = calls.filter((ts) => now - ts <= windowMs);
|
|
943
|
+
if (filtered.length >= maxCalls) {
|
|
944
|
+
SPAM_BUCKETS.set(bucketKey, filtered);
|
|
945
|
+
return false;
|
|
946
|
+
}
|
|
947
|
+
if (filtered.length === 0 && calls.length > 0) {
|
|
948
|
+
SPAM_BUCKETS.delete(bucketKey);
|
|
949
|
+
}
|
|
950
|
+
filtered.push(now);
|
|
951
|
+
SPAM_BUCKETS.set(bucketKey, filtered);
|
|
952
|
+
return true;
|
|
953
|
+
}
|
|
954
|
+
function deepGet(obj, path) {
|
|
955
|
+
// Try direct key first (own property only)
|
|
956
|
+
if (Object.prototype.hasOwnProperty.call(obj, path))
|
|
957
|
+
return obj[path];
|
|
958
|
+
// Try dot-path
|
|
959
|
+
const parts = path.split('.');
|
|
960
|
+
let current = obj;
|
|
961
|
+
for (const part of parts) {
|
|
962
|
+
if (current == null || typeof current !== 'object')
|
|
963
|
+
return undefined;
|
|
964
|
+
if (!Object.prototype.hasOwnProperty.call(current, part))
|
|
965
|
+
return undefined;
|
|
966
|
+
current = current[part];
|
|
967
|
+
}
|
|
968
|
+
return current;
|
|
969
|
+
}
|
|
970
|
+
//# sourceMappingURL=engine.js.map
|