@kinotic-ai/spawn 0.4.0 → 0.5.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.
@@ -14,7 +14,7 @@
14
14
  });
15
15
  };
16
16
 
17
- // ../../node_modules/.bun/liquidjs@10.25.7/node_modules/liquidjs/dist/liquid.browser.mjs
17
+ // ../../node_modules/.bun/liquidjs@10.27.0/node_modules/liquidjs/dist/liquid.browser.mjs
18
18
  class Token {
19
19
  constructor(kind, input, begin, end, file) {
20
20
  this.kind = kind;
@@ -154,10 +154,10 @@
154
154
  }
155
155
  function pad(str, length, ch, add) {
156
156
  str = String(str);
157
- let n = length - str.length;
158
- while (n-- > 0)
159
- str = add(str, ch);
160
- return str;
157
+ const n = length - str.length;
158
+ if (n <= 0)
159
+ return str;
160
+ return add(str, ch.repeat(n));
161
161
  }
162
162
  function identify(val) {
163
163
  return val;
@@ -703,8 +703,10 @@ From ` + this.originalError.stack;
703
703
  m: (d) => d.getMonth() + 1,
704
704
  M: (d) => d.getMinutes(),
705
705
  N: (d, opts) => {
706
+ var _a;
706
707
  const width = Number(opts.width) || 9;
707
708
  const str = String(d.getMilliseconds()).slice(0, width);
709
+ (_a = opts.memoryLimit) === null || _a === undefined || _a.use(width - str.length);
708
710
  return padEnd(str, width, "0");
709
711
  },
710
712
  p: (d) => d.getHours() < 12 ? "AM" : "PM",
@@ -728,18 +730,18 @@ From ` + this.originalError.stack;
728
730
  "%": () => "%"
729
731
  };
730
732
  formatCodes.h = formatCodes.b;
731
- function strftime(d, formatStr) {
733
+ function strftime(d, formatStr, memoryLimit) {
732
734
  let output = "";
733
735
  let remaining = formatStr;
734
736
  let match;
735
737
  while (match = rFormat.exec(remaining)) {
736
738
  output += remaining.slice(0, match.index);
737
739
  remaining = remaining.slice(match.index + match[0].length);
738
- output += format(d, match);
740
+ output += format(d, match, memoryLimit);
739
741
  }
740
742
  return output + remaining;
741
743
  }
742
- function format(d, match) {
744
+ function format(d, match, memoryLimit) {
743
745
  const [input, flagStr = "", width, modifier, conversion] = match;
744
746
  const convert = formatCodes[conversion];
745
747
  if (!convert)
@@ -747,7 +749,7 @@ From ` + this.originalError.stack;
747
749
  const flags = {};
748
750
  for (const flag of flagStr)
749
751
  flags[flag] = true;
750
- let ret = String(convert(d, { flags, width, modifier }));
752
+ let ret = String(convert(d, { flags, width, modifier, memoryLimit }));
751
753
  let padChar = padSpaceChars.has(conversion) ? " " : "0";
752
754
  let padWidth = width || padWidths[conversion] || 0;
753
755
  if (flags["^"])
@@ -760,6 +762,7 @@ From ` + this.originalError.stack;
760
762
  padChar = "0";
761
763
  if (flags["-"])
762
764
  padWidth = 0;
765
+ memoryLimit === null || memoryLimit === undefined || memoryLimit.use(Number(padWidth) - ret.length);
763
766
  return padStart(ret, padWidth, padChar);
764
767
  }
765
768
  function getDateTimeFormat() {
@@ -1192,6 +1195,7 @@ From ` + this.originalError.stack;
1192
1195
  if (!emitter) {
1193
1196
  emitter = ctx.opts.keepOutputType ? new KeepingTypeEmitter : new SimpleEmitter;
1194
1197
  }
1198
+ ctx.renderLimit.check(getPerformance().now());
1195
1199
  const errors = [];
1196
1200
  for (const tpl of templates) {
1197
1201
  ctx.renderLimit.check(getPerformance().now());
@@ -1577,7 +1581,28 @@ From ` + this.originalError.stack;
1577
1581
  function strip_html(v) {
1578
1582
  const str = stringify(v);
1579
1583
  this.context.memoryLimit.use(str.length);
1580
- return str.replace(/<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>|<.*?>|<!--[\s\S]*?-->/g, "");
1584
+ const blocks = new Map([["<script", "</script>"], ["<style", "</style>"], ["<!--", "-->"], ["<", ">"]]);
1585
+ let out = "";
1586
+ let i = 0;
1587
+ while (i < str.length) {
1588
+ const lt = str.indexOf("<", i);
1589
+ if (lt < 0)
1590
+ return out + str.slice(i);
1591
+ out += str.slice(i, lt);
1592
+ for (const [opener, closer] of blocks) {
1593
+ if (!str.startsWith(opener, lt))
1594
+ continue;
1595
+ const e = str.indexOf(closer, lt + opener.length);
1596
+ if (e >= 0) {
1597
+ i = e + closer.length;
1598
+ break;
1599
+ }
1600
+ blocks.delete(opener);
1601
+ }
1602
+ if (i === lt)
1603
+ return out + str.slice(lt);
1604
+ }
1605
+ return out;
1581
1606
  }
1582
1607
  var htmlFilters = /* @__PURE__ */ Object.freeze({
1583
1608
  __proto__: null,
@@ -2845,10 +2870,17 @@ From ` + this.originalError.stack;
2845
2870
  function getKind(val) {
2846
2871
  return val ? val.kind : -1;
2847
2872
  }
2873
+ function createScope(from) {
2874
+ const scope = Object.create(null);
2875
+ if (from)
2876
+ Object.assign(scope, from);
2877
+ return scope;
2878
+ }
2879
+
2848
2880
  class Context {
2849
2881
  constructor(env = {}, opts = defaultOptions, renderOptions = {}, { memoryLimit, renderLimit } = {}) {
2850
2882
  var _a, _b, _c, _d, _e;
2851
- this.scopes = [{}];
2883
+ this.scopes = [createScope()];
2852
2884
  this.registers = {};
2853
2885
  this.breakCalled = false;
2854
2886
  this.continueCalled = false;
@@ -2913,7 +2945,8 @@ From ` + this.originalError.stack;
2913
2945
  return new Context(scope, this.opts, {
2914
2946
  sync: this.sync,
2915
2947
  globals: this.globals,
2916
- strictVariables: this.strictVariables
2948
+ strictVariables: this.strictVariables,
2949
+ ownPropertyOnly: this.ownPropertyOnly
2917
2950
  }, {
2918
2951
  renderLimit: this.renderLimit,
2919
2952
  memoryLimit: this.memoryLimit
@@ -3307,15 +3340,16 @@ From ` + this.originalError.stack;
3307
3340
  sample
3308
3341
  });
3309
3342
  function date(v, format2, timezoneOffset) {
3310
- var _a, _b, _c;
3311
- const size2 = ((_a = v === null || v === undefined ? undefined : v.length) !== null && _a !== undefined ? _a : 0) + ((_b = format2 === null || format2 === undefined ? undefined : format2.length) !== null && _b !== undefined ? _b : 0) + ((_c = timezoneOffset === null || timezoneOffset === undefined ? undefined : timezoneOffset.length) !== null && _c !== undefined ? _c : 0);
3343
+ var _a, _b;
3344
+ const size2 = ((_a = v === null || v === undefined ? undefined : v.length) !== null && _a !== undefined ? _a : 0) + ((_b = timezoneOffset === null || timezoneOffset === undefined ? undefined : timezoneOffset.length) !== null && _b !== undefined ? _b : 0);
3312
3345
  this.context.memoryLimit.use(size2);
3313
3346
  const date2 = parseDate(v, this.context.opts, timezoneOffset);
3314
3347
  if (!date2)
3315
3348
  return v;
3316
3349
  format2 = toValue(format2);
3317
3350
  format2 = isNil(format2) ? this.context.opts.dateFormat : stringify(format2);
3318
- return strftime(date2, format2);
3351
+ this.context.memoryLimit.use(format2.length);
3352
+ return strftime(date2, format2, this.context.memoryLimit);
3319
3353
  }
3320
3354
  function date_to_xmlschema(v) {
3321
3355
  return date.call(this, v, "%Y-%m-%dT%H:%M:%S%:z");
@@ -3333,11 +3367,12 @@ From ` + this.originalError.stack;
3333
3367
  const date2 = parseDate(v, this.context.opts);
3334
3368
  if (!date2)
3335
3369
  return v;
3370
+ const ml = this.context.memoryLimit;
3336
3371
  if (type === "ordinal") {
3337
3372
  const d = date2.getDate();
3338
- return style === "US" ? strftime(date2, `${month_type} ${d}%q, %Y`) : strftime(date2, `${d}%q ${month_type} %Y`);
3373
+ return style === "US" ? strftime(date2, `${month_type} ${d}%q, %Y`, ml) : strftime(date2, `${d}%q ${month_type} %Y`, ml);
3339
3374
  }
3340
- return strftime(date2, `%d ${month_type} %Y`);
3375
+ return strftime(date2, `%d ${month_type} %Y`, ml);
3341
3376
  }
3342
3377
  function parseDate(v, opts, timezoneOffset) {
3343
3378
  let date2;
@@ -3609,7 +3644,46 @@ From ` + this.originalError.stack;
3609
3644
  base64_encode,
3610
3645
  base64_decode
3611
3646
  });
3612
- var filters = Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, htmlFilters), mathFilters), urlFilters), arrayFilters), dateFilters), stringFilters), base64Filters), misc);
3647
+ function bufferToHex(buffer) {
3648
+ const bytes = new Uint8Array(buffer);
3649
+ let hex = "";
3650
+ for (let i = 0;i < bytes.length; i++) {
3651
+ hex += bytes[i].toString(16).padStart(2, "0");
3652
+ }
3653
+ return hex;
3654
+ }
3655
+ function sha256(str) {
3656
+ return __awaiter(this, undefined, undefined, function* () {
3657
+ const data = new TextEncoder().encode(str);
3658
+ const digest = yield crypto.subtle.digest("SHA-256", data);
3659
+ return bufferToHex(digest);
3660
+ });
3661
+ }
3662
+ function hmacSha256(str, key) {
3663
+ return __awaiter(this, undefined, undefined, function* () {
3664
+ const encoder = new TextEncoder;
3665
+ const cryptoKey = yield crypto.subtle.importKey("raw", encoder.encode(key), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
3666
+ const signature = yield crypto.subtle.sign("HMAC", cryptoKey, encoder.encode(str));
3667
+ return bufferToHex(signature);
3668
+ });
3669
+ }
3670
+ function sha256$1(value) {
3671
+ const str = stringify(value);
3672
+ this.context.memoryLimit.use(str.length);
3673
+ return sha256(str);
3674
+ }
3675
+ function hmac_sha256(value, key) {
3676
+ const str = stringify(value);
3677
+ const keyStr = stringify(key);
3678
+ this.context.memoryLimit.use(str.length + keyStr.length);
3679
+ return hmacSha256(str, keyStr);
3680
+ }
3681
+ var cryptoFilters = /* @__PURE__ */ Object.freeze({
3682
+ __proto__: null,
3683
+ sha256: sha256$1,
3684
+ hmac_sha256
3685
+ });
3686
+ var filters = Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, htmlFilters), mathFilters), urlFilters), arrayFilters), dateFilters), stringFilters), base64Filters), cryptoFilters), misc);
3613
3687
 
3614
3688
  class AssignTag extends Tag {
3615
3689
  constructor(token, remainTokens, liquid) {
@@ -3668,7 +3742,7 @@ From ` + this.originalError.stack;
3668
3742
  return;
3669
3743
  }
3670
3744
  const continueKey = "continue-" + this.variable + "-" + this.collection.getText();
3671
- ctx.push({ continue: ctx.getRegister(continueKey, {}) });
3745
+ ctx.push(createScope({ continue: ctx.getRegister(continueKey, {}) }));
3672
3746
  const hash = yield this.hash.render(ctx);
3673
3747
  ctx.pop();
3674
3748
  const modifiers = this.liquid.options.orderedFilterParameters ? Object.keys(hash).filter((x) => MODIFIERS.includes(x)) : MODIFIERS.filter((x) => hash[x] !== undefined);
@@ -3680,7 +3754,7 @@ From ` + this.originalError.stack;
3680
3754
  return reversed(collection2);
3681
3755
  }, collection);
3682
3756
  ctx.setRegister(continueKey, (hash["offset"] || 0) + collection.length);
3683
- const scope = { forloop: new ForloopDrop(collection.length, this.collection.getText(), this.variable) };
3757
+ const scope = createScope({ forloop: new ForloopDrop(collection.length, this.collection.getText(), this.variable) });
3684
3758
  ctx.push(scope);
3685
3759
  for (const item of collection) {
3686
3760
  scope[this.variable] = item;
@@ -4006,11 +4080,11 @@ From ` + this.originalError.stack;
4006
4080
  const saved = ctx.saveRegister("blocks", "blockMode");
4007
4081
  ctx.setRegister("blocks", {});
4008
4082
  ctx.setRegister("blockMode", BlockMode.OUTPUT);
4009
- const scope = yield hash.render(ctx);
4083
+ const scope = createScope(yield hash.render(ctx));
4010
4084
  if (withVar)
4011
4085
  scope[filepath] = yield evalToken(withVar, ctx);
4012
4086
  const templates = yield liquid._parsePartialFile(filepath, ctx.sync, this["currentFile"]);
4013
- ctx.push(ctx.opts.jekyllInclude ? { include: scope } : scope);
4087
+ ctx.push(ctx.opts.jekyllInclude ? createScope({ include: scope }) : scope);
4014
4088
  yield renderer.renderTemplates(templates, ctx, emitter);
4015
4089
  ctx.pop();
4016
4090
  ctx.restoreRegister(saved);
@@ -4199,7 +4273,7 @@ From ` + this.originalError.stack;
4199
4273
  if (blocks[""] === undefined)
4200
4274
  blocks[""] = (parent, emitter2) => emitter2.write(html);
4201
4275
  ctx.setRegister("blockMode", BlockMode.OUTPUT);
4202
- ctx.push(yield args.render(ctx));
4276
+ ctx.push(createScope(yield args.render(ctx)));
4203
4277
  yield renderer.renderTemplates(templates, ctx, emitter);
4204
4278
  ctx.pop();
4205
4279
  }
@@ -4259,7 +4333,7 @@ From ` + this.originalError.stack;
4259
4333
  if (stack.includes(self))
4260
4334
  throw new Error("block tag cannot be nested");
4261
4335
  stack.push(self);
4262
- ctx.push({ block: superBlock });
4336
+ ctx.push(createScope({ block: superBlock }));
4263
4337
  yield liquid.renderer.renderTemplates(templates, ctx, emitter);
4264
4338
  ctx.pop();
4265
4339
  stack.pop();
@@ -4343,7 +4417,7 @@ From ` + this.originalError.stack;
4343
4417
  const cols = args.cols || collection.length;
4344
4418
  const r = this.liquid.renderer;
4345
4419
  const tablerowloop = new TablerowloopDrop(collection.length, cols, this.collection.getText(), this.variable);
4346
- const scope = { tablerowloop };
4420
+ const scope = createScope({ tablerowloop });
4347
4421
  ctx.push(scope);
4348
4422
  for (let idx = 0;idx < collection.length; idx++, tablerowloop.next()) {
4349
4423
  scope[this.variable] = collection[idx];
@@ -4516,8 +4590,8 @@ From ` + this.originalError.stack;
4516
4590
  class Liquid {
4517
4591
  constructor(opts = {}) {
4518
4592
  this.renderer = new Render;
4519
- this.filters = {};
4520
- this.tags = {};
4593
+ this.filters = Object.create(null);
4594
+ this.tags = Object.create(null);
4521
4595
  this.options = normalize(opts);
4522
4596
  this.parser = new Parser(this);
4523
4597
  forOwn(tags, (conf, name) => this.registerTag(name, conf));
@@ -19014,23 +19088,7 @@ From ` + this.originalError.stack;
19014
19088
  this.engine.registerFilter("upperFirst", (v) => upperFirst(v));
19015
19089
  }
19016
19090
  async renderSpawn(spawn, options) {
19017
- const trees = [spawn];
19018
- const configs = [];
19019
- let currentConfig = this.parseConfig(spawn);
19020
- if (currentConfig) {
19021
- configs.push(currentConfig);
19022
- }
19023
- while (currentConfig?.inherits) {
19024
- if (!options?.loadInherited) {
19025
- throw new Error(`Spawn inherits '${currentConfig.inherits}' but no loadInherited callback was provided`);
19026
- }
19027
- const inherited = await options.loadInherited(currentConfig.inherits);
19028
- trees.push(inherited);
19029
- currentConfig = this.parseConfig(inherited);
19030
- if (currentConfig) {
19031
- configs.push(currentConfig);
19032
- }
19033
- }
19091
+ const { trees, configs } = await this.walkInheritance(spawn, options?.loadInherited);
19034
19092
  let globals = {};
19035
19093
  let propertySchemas = {};
19036
19094
  for (const config2 of [...configs].reverse()) {
@@ -19069,6 +19127,69 @@ From ` + this.originalError.stack;
19069
19127
  }
19070
19128
  return { files, sources, context };
19071
19129
  }
19130
+ async lint(spawn, options) {
19131
+ const { trees, configs } = await this.walkInheritance(spawn, options?.loadInherited);
19132
+ const declared = new Set;
19133
+ for (const config2 of configs) {
19134
+ if (config2.globals) {
19135
+ Object.keys(config2.globals).forEach((key) => declared.add(key));
19136
+ }
19137
+ if (config2.propertySchema) {
19138
+ Object.keys(config2.propertySchema).forEach((key) => declared.add(key));
19139
+ }
19140
+ }
19141
+ const usedIn = new Map;
19142
+ const record2 = (name, file2) => {
19143
+ let files = usedIn.get(name);
19144
+ if (!files) {
19145
+ files = new Set;
19146
+ usedIn.set(name, files);
19147
+ }
19148
+ files.add(file2);
19149
+ };
19150
+ const externalVars = (template) => Object.keys(this.engine.parseAndAnalyzeSync(template, undefined, { partials: false }).globals);
19151
+ for (const tree of trees) {
19152
+ for (const source of Object.keys(tree)) {
19153
+ const fileName = source.substring(source.lastIndexOf("/") + 1);
19154
+ if (IGNORED_FILE_NAMES.includes(fileName)) {
19155
+ continue;
19156
+ }
19157
+ externalVars(source).forEach((name) => record2(name, source));
19158
+ const content = tree[source];
19159
+ if (source.endsWith(".liquid") && typeof content === "string") {
19160
+ externalVars(content).forEach((name) => record2(name, source));
19161
+ }
19162
+ }
19163
+ }
19164
+ const undeclared = [];
19165
+ for (const [name, files] of usedIn) {
19166
+ if (!declared.has(name)) {
19167
+ undeclared.push({ name, files: [...files].sort() });
19168
+ }
19169
+ }
19170
+ undeclared.sort((a, b) => a.name.localeCompare(b.name));
19171
+ return { undeclared };
19172
+ }
19173
+ async walkInheritance(spawn, loadInherited) {
19174
+ const trees = [spawn];
19175
+ const configs = [];
19176
+ let currentConfig = this.parseConfig(spawn);
19177
+ if (currentConfig) {
19178
+ configs.push(currentConfig);
19179
+ }
19180
+ while (currentConfig?.inherits) {
19181
+ if (!loadInherited) {
19182
+ throw new Error(`Spawn inherits '${currentConfig.inherits}' but no loadInherited callback was provided`);
19183
+ }
19184
+ const inherited = await loadInherited(currentConfig.inherits);
19185
+ trees.push(inherited);
19186
+ currentConfig = this.parseConfig(inherited);
19187
+ if (currentConfig) {
19188
+ configs.push(currentConfig);
19189
+ }
19190
+ }
19191
+ return { trees, configs };
19192
+ }
19072
19193
  parseConfig(tree) {
19073
19194
  const raw2 = tree["spawn.json"];
19074
19195
  if (raw2 === undefined) {
package/dist/index.cjs CHANGED
@@ -104,23 +104,7 @@ class SpawnEngine {
104
104
  this.engine.registerFilter("upperFirst", (v) => upperFirst(v));
105
105
  }
106
106
  async renderSpawn(spawn, options) {
107
- const trees = [spawn];
108
- const configs = [];
109
- let currentConfig = this.parseConfig(spawn);
110
- if (currentConfig) {
111
- configs.push(currentConfig);
112
- }
113
- while (currentConfig?.inherits) {
114
- if (!options?.loadInherited) {
115
- throw new Error(`Spawn inherits '${currentConfig.inherits}' but no loadInherited callback was provided`);
116
- }
117
- const inherited = await options.loadInherited(currentConfig.inherits);
118
- trees.push(inherited);
119
- currentConfig = this.parseConfig(inherited);
120
- if (currentConfig) {
121
- configs.push(currentConfig);
122
- }
123
- }
107
+ const { trees, configs } = await this.walkInheritance(spawn, options?.loadInherited);
124
108
  let globals = {};
125
109
  let propertySchemas = {};
126
110
  for (const config of [...configs].reverse()) {
@@ -159,6 +143,69 @@ class SpawnEngine {
159
143
  }
160
144
  return { files, sources, context };
161
145
  }
146
+ async lint(spawn, options) {
147
+ const { trees, configs } = await this.walkInheritance(spawn, options?.loadInherited);
148
+ const declared = new Set;
149
+ for (const config of configs) {
150
+ if (config.globals) {
151
+ Object.keys(config.globals).forEach((key) => declared.add(key));
152
+ }
153
+ if (config.propertySchema) {
154
+ Object.keys(config.propertySchema).forEach((key) => declared.add(key));
155
+ }
156
+ }
157
+ const usedIn = new Map;
158
+ const record = (name, file) => {
159
+ let files = usedIn.get(name);
160
+ if (!files) {
161
+ files = new Set;
162
+ usedIn.set(name, files);
163
+ }
164
+ files.add(file);
165
+ };
166
+ const externalVars = (template) => Object.keys(this.engine.parseAndAnalyzeSync(template, undefined, { partials: false }).globals);
167
+ for (const tree of trees) {
168
+ for (const source of Object.keys(tree)) {
169
+ const fileName = source.substring(source.lastIndexOf("/") + 1);
170
+ if (IGNORED_FILE_NAMES.includes(fileName)) {
171
+ continue;
172
+ }
173
+ externalVars(source).forEach((name) => record(name, source));
174
+ const content = tree[source];
175
+ if (source.endsWith(".liquid") && typeof content === "string") {
176
+ externalVars(content).forEach((name) => record(name, source));
177
+ }
178
+ }
179
+ }
180
+ const undeclared = [];
181
+ for (const [name, files] of usedIn) {
182
+ if (!declared.has(name)) {
183
+ undeclared.push({ name, files: [...files].sort() });
184
+ }
185
+ }
186
+ undeclared.sort((a, b) => a.name.localeCompare(b.name));
187
+ return { undeclared };
188
+ }
189
+ async walkInheritance(spawn, loadInherited) {
190
+ const trees = [spawn];
191
+ const configs = [];
192
+ let currentConfig = this.parseConfig(spawn);
193
+ if (currentConfig) {
194
+ configs.push(currentConfig);
195
+ }
196
+ while (currentConfig?.inherits) {
197
+ if (!loadInherited) {
198
+ throw new Error(`Spawn inherits '${currentConfig.inherits}' but no loadInherited callback was provided`);
199
+ }
200
+ const inherited = await loadInherited(currentConfig.inherits);
201
+ trees.push(inherited);
202
+ currentConfig = this.parseConfig(inherited);
203
+ if (currentConfig) {
204
+ configs.push(currentConfig);
205
+ }
206
+ }
207
+ return { trees, configs };
208
+ }
162
209
  parseConfig(tree) {
163
210
  const raw = tree["spawn.json"];
164
211
  if (raw === undefined) {
package/dist/index.d.cts CHANGED
@@ -61,6 +61,31 @@ interface RenderSpawnOptions {
61
61
  loadInherited?: (ref: string) => Promise<SpawnTree>;
62
62
  }
63
63
  /**
64
+ * A variable referenced in a spawn's templates that is declared neither in
65
+ * propertySchema nor in globals, reported by {@link SpawnEngine#lint}.
66
+ *
67
+ * @see SpawnLintResult
68
+ */
69
+ interface UndeclaredVariable {
70
+ /** The variable name as referenced in the templates. */
71
+ name: string;
72
+ /**
73
+ * The spawn paths where the variable appears — file contents for
74
+ * {@code .liquid} files, and path templates for any file whose path contains
75
+ * the variable.
76
+ */
77
+ files: string[];
78
+ }
79
+ /**
80
+ * The outcome of {@link SpawnEngine#lint}: the variables a spawn's templates
81
+ * reference that are not declared in propertySchema or globals. A spawn is
82
+ * lint-clean when {@code undeclared} is empty.
83
+ */
84
+ interface SpawnLintResult {
85
+ /** Referenced-but-undeclared variables, sorted by name. */
86
+ undeclared: UndeclaredVariable[];
87
+ }
88
+ /**
64
89
  * The outcome of rendering a spawn.
65
90
  */
66
91
  interface SpawnRenderResult {
@@ -113,7 +138,18 @@ declare class SpawnEngine {
113
138
  * context used, including values added by the property resolver.
114
139
  */
115
140
  renderSpawn(spawn: SpawnTree, options?: RenderSpawnOptions): Promise<SpawnRenderResult>;
141
+ /**
142
+ * Reports the variables a spawn's templates reference that are declared
143
+ * neither in propertySchema nor in globals, across the full inheritance
144
+ * chain. Variables come from the path of every file plus the contents of
145
+ * {@code .liquid} files; registered filters are not mistaken for variables.
146
+ * A clean spawn has an empty {@code undeclared} list.
147
+ */
148
+ lint(spawn: SpawnTree, options?: {
149
+ loadInherited?: (ref: string) => Promise<SpawnTree>;
150
+ }): Promise<SpawnLintResult>;
151
+ private walkInheritance;
116
152
  private parseConfig;
117
153
  private resolveMissingProperties;
118
154
  }
119
- export { SpawnTree, SpawnRenderResult, SpawnEngine, RenderSpawnOptions, PropertySchema, PropertyResolver };
155
+ export { UndeclaredVariable, SpawnTree, SpawnRenderResult, SpawnLintResult, SpawnEngine, RenderSpawnOptions, PropertySchema, PropertyResolver };
package/dist/index.d.ts CHANGED
@@ -61,6 +61,31 @@ interface RenderSpawnOptions {
61
61
  loadInherited?: (ref: string) => Promise<SpawnTree>;
62
62
  }
63
63
  /**
64
+ * A variable referenced in a spawn's templates that is declared neither in
65
+ * propertySchema nor in globals, reported by {@link SpawnEngine#lint}.
66
+ *
67
+ * @see SpawnLintResult
68
+ */
69
+ interface UndeclaredVariable {
70
+ /** The variable name as referenced in the templates. */
71
+ name: string;
72
+ /**
73
+ * The spawn paths where the variable appears — file contents for
74
+ * {@code .liquid} files, and path templates for any file whose path contains
75
+ * the variable.
76
+ */
77
+ files: string[];
78
+ }
79
+ /**
80
+ * The outcome of {@link SpawnEngine#lint}: the variables a spawn's templates
81
+ * reference that are not declared in propertySchema or globals. A spawn is
82
+ * lint-clean when {@code undeclared} is empty.
83
+ */
84
+ interface SpawnLintResult {
85
+ /** Referenced-but-undeclared variables, sorted by name. */
86
+ undeclared: UndeclaredVariable[];
87
+ }
88
+ /**
64
89
  * The outcome of rendering a spawn.
65
90
  */
66
91
  interface SpawnRenderResult {
@@ -113,7 +138,18 @@ declare class SpawnEngine {
113
138
  * context used, including values added by the property resolver.
114
139
  */
115
140
  renderSpawn(spawn: SpawnTree, options?: RenderSpawnOptions): Promise<SpawnRenderResult>;
141
+ /**
142
+ * Reports the variables a spawn's templates reference that are declared
143
+ * neither in propertySchema nor in globals, across the full inheritance
144
+ * chain. Variables come from the path of every file plus the contents of
145
+ * {@code .liquid} files; registered filters are not mistaken for variables.
146
+ * A clean spawn has an empty {@code undeclared} list.
147
+ */
148
+ lint(spawn: SpawnTree, options?: {
149
+ loadInherited?: (ref: string) => Promise<SpawnTree>;
150
+ }): Promise<SpawnLintResult>;
151
+ private walkInheritance;
116
152
  private parseConfig;
117
153
  private resolveMissingProperties;
118
154
  }
119
- export { SpawnTree, SpawnRenderResult, SpawnEngine, RenderSpawnOptions, PropertySchema, PropertyResolver };
155
+ export { UndeclaredVariable, SpawnTree, SpawnRenderResult, SpawnLintResult, SpawnEngine, RenderSpawnOptions, PropertySchema, PropertyResolver };
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  SpawnEngine
3
- } from "./shared/spawn-4k8xtpe9.js";
3
+ } from "./shared/spawn-1h1k8w8r.js";
4
4
  export {
5
5
  SpawnEngine
6
6
  };
@@ -64,6 +64,7 @@ var __export = (target, all) => {
64
64
  // packages/spawn/src/node/index.ts
65
65
  var exports_node = {};
66
66
  __export(exports_node, {
67
+ lintSpawnDir: () => lintSpawnDir,
67
68
  assertPathWithin: () => assertPathWithin,
68
69
  NodeSpawnRenderer: () => NodeSpawnRenderer
69
70
  });
@@ -108,23 +109,7 @@ class SpawnEngine {
108
109
  this.engine.registerFilter("upperFirst", (v) => upperFirst(v));
109
110
  }
110
111
  async renderSpawn(spawn, options) {
111
- const trees = [spawn];
112
- const configs = [];
113
- let currentConfig = this.parseConfig(spawn);
114
- if (currentConfig) {
115
- configs.push(currentConfig);
116
- }
117
- while (currentConfig?.inherits) {
118
- if (!options?.loadInherited) {
119
- throw new Error(`Spawn inherits '${currentConfig.inherits}' but no loadInherited callback was provided`);
120
- }
121
- const inherited = await options.loadInherited(currentConfig.inherits);
122
- trees.push(inherited);
123
- currentConfig = this.parseConfig(inherited);
124
- if (currentConfig) {
125
- configs.push(currentConfig);
126
- }
127
- }
112
+ const { trees, configs } = await this.walkInheritance(spawn, options?.loadInherited);
128
113
  let globals = {};
129
114
  let propertySchemas = {};
130
115
  for (const config of [...configs].reverse()) {
@@ -163,6 +148,69 @@ class SpawnEngine {
163
148
  }
164
149
  return { files, sources, context };
165
150
  }
151
+ async lint(spawn, options) {
152
+ const { trees, configs } = await this.walkInheritance(spawn, options?.loadInherited);
153
+ const declared = new Set;
154
+ for (const config of configs) {
155
+ if (config.globals) {
156
+ Object.keys(config.globals).forEach((key) => declared.add(key));
157
+ }
158
+ if (config.propertySchema) {
159
+ Object.keys(config.propertySchema).forEach((key) => declared.add(key));
160
+ }
161
+ }
162
+ const usedIn = new Map;
163
+ const record = (name, file) => {
164
+ let files = usedIn.get(name);
165
+ if (!files) {
166
+ files = new Set;
167
+ usedIn.set(name, files);
168
+ }
169
+ files.add(file);
170
+ };
171
+ const externalVars = (template) => Object.keys(this.engine.parseAndAnalyzeSync(template, undefined, { partials: false }).globals);
172
+ for (const tree of trees) {
173
+ for (const source of Object.keys(tree)) {
174
+ const fileName = source.substring(source.lastIndexOf("/") + 1);
175
+ if (IGNORED_FILE_NAMES.includes(fileName)) {
176
+ continue;
177
+ }
178
+ externalVars(source).forEach((name) => record(name, source));
179
+ const content = tree[source];
180
+ if (source.endsWith(".liquid") && typeof content === "string") {
181
+ externalVars(content).forEach((name) => record(name, source));
182
+ }
183
+ }
184
+ }
185
+ const undeclared = [];
186
+ for (const [name, files] of usedIn) {
187
+ if (!declared.has(name)) {
188
+ undeclared.push({ name, files: [...files].sort() });
189
+ }
190
+ }
191
+ undeclared.sort((a, b) => a.name.localeCompare(b.name));
192
+ return { undeclared };
193
+ }
194
+ async walkInheritance(spawn, loadInherited) {
195
+ const trees = [spawn];
196
+ const configs = [];
197
+ let currentConfig = this.parseConfig(spawn);
198
+ if (currentConfig) {
199
+ configs.push(currentConfig);
200
+ }
201
+ while (currentConfig?.inherits) {
202
+ if (!loadInherited) {
203
+ throw new Error(`Spawn inherits '${currentConfig.inherits}' but no loadInherited callback was provided`);
204
+ }
205
+ const inherited = await loadInherited(currentConfig.inherits);
206
+ trees.push(inherited);
207
+ currentConfig = this.parseConfig(inherited);
208
+ if (currentConfig) {
209
+ configs.push(currentConfig);
210
+ }
211
+ }
212
+ return { trees, configs };
213
+ }
166
214
  parseConfig(tree) {
167
215
  const raw = tree["spawn.json"];
168
216
  if (raw === undefined) {
@@ -207,6 +255,36 @@ function assertContained(root, resolved) {
207
255
  function assertPathWithin(root, target) {
208
256
  return assertContained(root, import_node_path.default.resolve(root, target));
209
257
  }
258
+ async function loadSpawnTree(dir) {
259
+ const tree = {};
260
+ const entries = await import_promises.default.readdir(dir, { recursive: true, withFileTypes: true });
261
+ for (const entry of entries) {
262
+ if (!entry.isFile()) {
263
+ continue;
264
+ }
265
+ const filePath = import_node_path.default.join(entry.parentPath, entry.name);
266
+ const treePath = import_node_path.default.relative(dir, filePath).split(import_node_path.default.sep).join("/");
267
+ if (treePath.endsWith(".liquid")) {
268
+ tree[treePath] = await import_promises.default.readFile(filePath, { encoding: "utf8" });
269
+ } else {
270
+ tree[treePath] = await import_promises.default.readFile(filePath);
271
+ }
272
+ }
273
+ return tree;
274
+ }
275
+ function diskInheritanceLoader(spawnRoot) {
276
+ let currentDir = spawnRoot;
277
+ return async (ref) => {
278
+ currentDir = assertContained(spawnRoot, import_node_path.default.resolve(currentDir, ref));
279
+ if (!import_node_fs.default.existsSync(import_node_path.default.resolve(currentDir, "spawn.json"))) {
280
+ throw new Error(`Inherited spawn ${import_node_path.default.resolve(currentDir, "spawn.json")} does not exist`);
281
+ }
282
+ return loadSpawnTree(currentDir);
283
+ };
284
+ }
285
+ async function lintSpawnDir(dir) {
286
+ return new SpawnEngine().lint(await loadSpawnTree(dir), { loadInherited: diskInheritanceLoader(dir) });
287
+ }
210
288
 
211
289
  class NodeSpawnRenderer {
212
290
  engine = new SpawnEngine;
@@ -214,38 +292,14 @@ class NodeSpawnRenderer {
214
292
  if (import_node_fs.default.existsSync(destination)) {
215
293
  throw new Error(`The target directory ${destination} already exists`);
216
294
  }
217
- let currentDir = spawnDir;
218
- const result = await this.engine.renderSpawn(await this.loadSpawnTree(spawnDir), {
295
+ const result = await this.engine.renderSpawn(await loadSpawnTree(spawnDir), {
219
296
  context: options?.context,
220
297
  propertyResolver: options?.propertyResolver,
221
- loadInherited: async (ref) => {
222
- currentDir = assertContained(spawnDir, import_node_path.default.resolve(currentDir, ref));
223
- if (!import_node_fs.default.existsSync(import_node_path.default.resolve(currentDir, "spawn.json"))) {
224
- throw new Error(`Inherited spawn ${import_node_path.default.resolve(currentDir, "spawn.json")} does not exist`);
225
- }
226
- return this.loadSpawnTree(currentDir);
227
- }
298
+ loadInherited: diskInheritanceLoader(spawnDir)
228
299
  });
229
300
  await this.writeSpawnTree(result.files, destination);
230
301
  return result.context;
231
302
  }
232
- async loadSpawnTree(dir) {
233
- const tree = {};
234
- const entries = await import_promises.default.readdir(dir, { recursive: true, withFileTypes: true });
235
- for (const entry of entries) {
236
- if (!entry.isFile()) {
237
- continue;
238
- }
239
- const filePath = import_node_path.default.join(entry.parentPath, entry.name);
240
- const treePath = import_node_path.default.relative(dir, filePath).split(import_node_path.default.sep).join("/");
241
- if (treePath.endsWith(".liquid")) {
242
- tree[treePath] = await import_promises.default.readFile(filePath, { encoding: "utf8" });
243
- } else {
244
- tree[treePath] = await import_promises.default.readFile(filePath);
245
- }
246
- }
247
- return tree;
248
- }
249
303
  async writeSpawnTree(tree, destination) {
250
304
  await import_promises.default.mkdir(destination, { recursive: true });
251
305
  for (const [treePath, content] of Object.entries(tree)) {
@@ -31,6 +31,31 @@ interface PropertyResolver {
31
31
  resolve(key: string, schema: PropertySchema, message: string, defaultValue?: unknown): Promise<unknown>;
32
32
  }
33
33
  /**
34
+ * A variable referenced in a spawn's templates that is declared neither in
35
+ * propertySchema nor in globals, reported by {@link SpawnEngine#lint}.
36
+ *
37
+ * @see SpawnLintResult
38
+ */
39
+ interface UndeclaredVariable {
40
+ /** The variable name as referenced in the templates. */
41
+ name: string;
42
+ /**
43
+ * The spawn paths where the variable appears — file contents for
44
+ * {@code .liquid} files, and path templates for any file whose path contains
45
+ * the variable.
46
+ */
47
+ files: string[];
48
+ }
49
+ /**
50
+ * The outcome of {@link SpawnEngine#lint}: the variables a spawn's templates
51
+ * reference that are not declared in propertySchema or globals. A spawn is
52
+ * lint-clean when {@code undeclared} is empty.
53
+ */
54
+ interface SpawnLintResult {
55
+ /** Referenced-but-undeclared variables, sorted by name. */
56
+ undeclared: UndeclaredVariable[];
57
+ }
58
+ /**
34
59
  * Resolves {@code target} against {@code root} and asserts the result stays at or
35
60
  * below {@code root}, so a path can't escape the directory being operated in via
36
61
  * {@code ..} (whether authored that way or injected through a property value).
@@ -38,6 +63,12 @@ interface PropertyResolver {
38
63
  */
39
64
  declare function assertPathWithin(root: string, target: string): string;
40
65
  /**
66
+ * Reports the variables a spawn directory's templates reference that are
67
+ * declared neither in propertySchema nor in globals (following inheritance on
68
+ * disk, confined to {@code dir}). See {@link SpawnEngine#lint}.
69
+ */
70
+ declare function lintSpawnDir(dir: string): Promise<SpawnLintResult>;
71
+ /**
41
72
  * Options for {@link NodeSpawnRenderer#render}.
42
73
  */
43
74
  interface NodeRenderOptions {
@@ -66,7 +97,6 @@ declare class NodeSpawnRenderer {
66
97
  * rendered path escapes its root, or a required property has no value
67
98
  */
68
99
  render(spawnDir: string, destination: string, options?: NodeRenderOptions): Promise<Record<string, unknown>>;
69
- private loadSpawnTree;
70
100
  private writeSpawnTree;
71
101
  }
72
- export { assertPathWithin, NodeSpawnRenderer, NodeRenderOptions };
102
+ export { lintSpawnDir, assertPathWithin, NodeSpawnRenderer, NodeRenderOptions };
@@ -31,6 +31,31 @@ interface PropertyResolver {
31
31
  resolve(key: string, schema: PropertySchema, message: string, defaultValue?: unknown): Promise<unknown>;
32
32
  }
33
33
  /**
34
+ * A variable referenced in a spawn's templates that is declared neither in
35
+ * propertySchema nor in globals, reported by {@link SpawnEngine#lint}.
36
+ *
37
+ * @see SpawnLintResult
38
+ */
39
+ interface UndeclaredVariable {
40
+ /** The variable name as referenced in the templates. */
41
+ name: string;
42
+ /**
43
+ * The spawn paths where the variable appears — file contents for
44
+ * {@code .liquid} files, and path templates for any file whose path contains
45
+ * the variable.
46
+ */
47
+ files: string[];
48
+ }
49
+ /**
50
+ * The outcome of {@link SpawnEngine#lint}: the variables a spawn's templates
51
+ * reference that are not declared in propertySchema or globals. A spawn is
52
+ * lint-clean when {@code undeclared} is empty.
53
+ */
54
+ interface SpawnLintResult {
55
+ /** Referenced-but-undeclared variables, sorted by name. */
56
+ undeclared: UndeclaredVariable[];
57
+ }
58
+ /**
34
59
  * Resolves {@code target} against {@code root} and asserts the result stays at or
35
60
  * below {@code root}, so a path can't escape the directory being operated in via
36
61
  * {@code ..} (whether authored that way or injected through a property value).
@@ -38,6 +63,12 @@ interface PropertyResolver {
38
63
  */
39
64
  declare function assertPathWithin(root: string, target: string): string;
40
65
  /**
66
+ * Reports the variables a spawn directory's templates reference that are
67
+ * declared neither in propertySchema nor in globals (following inheritance on
68
+ * disk, confined to {@code dir}). See {@link SpawnEngine#lint}.
69
+ */
70
+ declare function lintSpawnDir(dir: string): Promise<SpawnLintResult>;
71
+ /**
41
72
  * Options for {@link NodeSpawnRenderer#render}.
42
73
  */
43
74
  interface NodeRenderOptions {
@@ -66,7 +97,6 @@ declare class NodeSpawnRenderer {
66
97
  * rendered path escapes its root, or a required property has no value
67
98
  */
68
99
  render(spawnDir: string, destination: string, options?: NodeRenderOptions): Promise<Record<string, unknown>>;
69
- private loadSpawnTree;
70
100
  private writeSpawnTree;
71
101
  }
72
- export { assertPathWithin, NodeSpawnRenderer, NodeRenderOptions };
102
+ export { lintSpawnDir, assertPathWithin, NodeSpawnRenderer, NodeRenderOptions };
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  SpawnEngine
3
- } from "../shared/spawn-4k8xtpe9.js";
3
+ } from "../shared/spawn-1h1k8w8r.js";
4
4
 
5
5
  // packages/spawn/src/node/index.ts
6
6
  import fs from "node:fs";
@@ -16,6 +16,36 @@ function assertContained(root, resolved) {
16
16
  function assertPathWithin(root, target) {
17
17
  return assertContained(root, path.resolve(root, target));
18
18
  }
19
+ async function loadSpawnTree(dir) {
20
+ const tree = {};
21
+ const entries = await fsP.readdir(dir, { recursive: true, withFileTypes: true });
22
+ for (const entry of entries) {
23
+ if (!entry.isFile()) {
24
+ continue;
25
+ }
26
+ const filePath = path.join(entry.parentPath, entry.name);
27
+ const treePath = path.relative(dir, filePath).split(path.sep).join("/");
28
+ if (treePath.endsWith(".liquid")) {
29
+ tree[treePath] = await fsP.readFile(filePath, { encoding: "utf8" });
30
+ } else {
31
+ tree[treePath] = await fsP.readFile(filePath);
32
+ }
33
+ }
34
+ return tree;
35
+ }
36
+ function diskInheritanceLoader(spawnRoot) {
37
+ let currentDir = spawnRoot;
38
+ return async (ref) => {
39
+ currentDir = assertContained(spawnRoot, path.resolve(currentDir, ref));
40
+ if (!fs.existsSync(path.resolve(currentDir, "spawn.json"))) {
41
+ throw new Error(`Inherited spawn ${path.resolve(currentDir, "spawn.json")} does not exist`);
42
+ }
43
+ return loadSpawnTree(currentDir);
44
+ };
45
+ }
46
+ async function lintSpawnDir(dir) {
47
+ return new SpawnEngine().lint(await loadSpawnTree(dir), { loadInherited: diskInheritanceLoader(dir) });
48
+ }
19
49
 
20
50
  class NodeSpawnRenderer {
21
51
  engine = new SpawnEngine;
@@ -23,38 +53,14 @@ class NodeSpawnRenderer {
23
53
  if (fs.existsSync(destination)) {
24
54
  throw new Error(`The target directory ${destination} already exists`);
25
55
  }
26
- let currentDir = spawnDir;
27
- const result = await this.engine.renderSpawn(await this.loadSpawnTree(spawnDir), {
56
+ const result = await this.engine.renderSpawn(await loadSpawnTree(spawnDir), {
28
57
  context: options?.context,
29
58
  propertyResolver: options?.propertyResolver,
30
- loadInherited: async (ref) => {
31
- currentDir = assertContained(spawnDir, path.resolve(currentDir, ref));
32
- if (!fs.existsSync(path.resolve(currentDir, "spawn.json"))) {
33
- throw new Error(`Inherited spawn ${path.resolve(currentDir, "spawn.json")} does not exist`);
34
- }
35
- return this.loadSpawnTree(currentDir);
36
- }
59
+ loadInherited: diskInheritanceLoader(spawnDir)
37
60
  });
38
61
  await this.writeSpawnTree(result.files, destination);
39
62
  return result.context;
40
63
  }
41
- async loadSpawnTree(dir) {
42
- const tree = {};
43
- const entries = await fsP.readdir(dir, { recursive: true, withFileTypes: true });
44
- for (const entry of entries) {
45
- if (!entry.isFile()) {
46
- continue;
47
- }
48
- const filePath = path.join(entry.parentPath, entry.name);
49
- const treePath = path.relative(dir, filePath).split(path.sep).join("/");
50
- if (treePath.endsWith(".liquid")) {
51
- tree[treePath] = await fsP.readFile(filePath, { encoding: "utf8" });
52
- } else {
53
- tree[treePath] = await fsP.readFile(filePath);
54
- }
55
- }
56
- return tree;
57
- }
58
64
  async writeSpawnTree(tree, destination) {
59
65
  await fsP.mkdir(destination, { recursive: true });
60
66
  for (const [treePath, content] of Object.entries(tree)) {
@@ -65,6 +71,7 @@ class NodeSpawnRenderer {
65
71
  }
66
72
  }
67
73
  export {
74
+ lintSpawnDir,
68
75
  assertPathWithin,
69
76
  NodeSpawnRenderer
70
77
  };
@@ -34,23 +34,7 @@ class SpawnEngine {
34
34
  this.engine.registerFilter("upperFirst", (v) => upperFirst(v));
35
35
  }
36
36
  async renderSpawn(spawn, options) {
37
- const trees = [spawn];
38
- const configs = [];
39
- let currentConfig = this.parseConfig(spawn);
40
- if (currentConfig) {
41
- configs.push(currentConfig);
42
- }
43
- while (currentConfig?.inherits) {
44
- if (!options?.loadInherited) {
45
- throw new Error(`Spawn inherits '${currentConfig.inherits}' but no loadInherited callback was provided`);
46
- }
47
- const inherited = await options.loadInherited(currentConfig.inherits);
48
- trees.push(inherited);
49
- currentConfig = this.parseConfig(inherited);
50
- if (currentConfig) {
51
- configs.push(currentConfig);
52
- }
53
- }
37
+ const { trees, configs } = await this.walkInheritance(spawn, options?.loadInherited);
54
38
  let globals = {};
55
39
  let propertySchemas = {};
56
40
  for (const config of [...configs].reverse()) {
@@ -89,6 +73,69 @@ class SpawnEngine {
89
73
  }
90
74
  return { files, sources, context };
91
75
  }
76
+ async lint(spawn, options) {
77
+ const { trees, configs } = await this.walkInheritance(spawn, options?.loadInherited);
78
+ const declared = new Set;
79
+ for (const config of configs) {
80
+ if (config.globals) {
81
+ Object.keys(config.globals).forEach((key) => declared.add(key));
82
+ }
83
+ if (config.propertySchema) {
84
+ Object.keys(config.propertySchema).forEach((key) => declared.add(key));
85
+ }
86
+ }
87
+ const usedIn = new Map;
88
+ const record = (name, file) => {
89
+ let files = usedIn.get(name);
90
+ if (!files) {
91
+ files = new Set;
92
+ usedIn.set(name, files);
93
+ }
94
+ files.add(file);
95
+ };
96
+ const externalVars = (template) => Object.keys(this.engine.parseAndAnalyzeSync(template, undefined, { partials: false }).globals);
97
+ for (const tree of trees) {
98
+ for (const source of Object.keys(tree)) {
99
+ const fileName = source.substring(source.lastIndexOf("/") + 1);
100
+ if (IGNORED_FILE_NAMES.includes(fileName)) {
101
+ continue;
102
+ }
103
+ externalVars(source).forEach((name) => record(name, source));
104
+ const content = tree[source];
105
+ if (source.endsWith(".liquid") && typeof content === "string") {
106
+ externalVars(content).forEach((name) => record(name, source));
107
+ }
108
+ }
109
+ }
110
+ const undeclared = [];
111
+ for (const [name, files] of usedIn) {
112
+ if (!declared.has(name)) {
113
+ undeclared.push({ name, files: [...files].sort() });
114
+ }
115
+ }
116
+ undeclared.sort((a, b) => a.name.localeCompare(b.name));
117
+ return { undeclared };
118
+ }
119
+ async walkInheritance(spawn, loadInherited) {
120
+ const trees = [spawn];
121
+ const configs = [];
122
+ let currentConfig = this.parseConfig(spawn);
123
+ if (currentConfig) {
124
+ configs.push(currentConfig);
125
+ }
126
+ while (currentConfig?.inherits) {
127
+ if (!loadInherited) {
128
+ throw new Error(`Spawn inherits '${currentConfig.inherits}' but no loadInherited callback was provided`);
129
+ }
130
+ const inherited = await loadInherited(currentConfig.inherits);
131
+ trees.push(inherited);
132
+ currentConfig = this.parseConfig(inherited);
133
+ if (currentConfig) {
134
+ configs.push(currentConfig);
135
+ }
136
+ }
137
+ return { trees, configs };
138
+ }
92
139
  parseConfig(tree) {
93
140
  const raw = tree["spawn.json"];
94
141
  if (raw === undefined) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kinotic-ai/spawn",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist"
@@ -43,8 +43,8 @@
43
43
  "build:graal-spawn-renderer": "bun build ./src/graalSpawnRendererMain.ts --outfile ./dist/graal-spawn-renderer.js --format=iife --target=browser"
44
44
  },
45
45
  "dependencies": {
46
- "liquidjs": "10.25.7",
47
- "zod": "^4.3.6"
46
+ "liquidjs": "10.27.0",
47
+ "zod": "4.4.3"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@types/node": "^25.3.2",