@dallaylaen/ski-interpreter 2.4.0 → 2.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,44 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [2.5.0] - 2026-03-15
9
+
10
+ ### BREAKING CHANGES
11
+
12
+ - `affine: true` in quests now means "has no duplicating subterms" rather than "non-duplicating as a whole."
13
+ Aliases are exempted from this check, so a solution `SK`
14
+ will not pass as S is duplicating, but `false=SK; false`
15
+ will because `false` (as a whole term) _is_ affine.
16
+
17
+ ### Added
18
+
19
+ - `SKI.schemas.FormatOptions`: zod schema for validating format options.
20
+ - `bin/ski.js`: global `--format` option.
21
+ - `bin/ski.js`: `!format` command in the REPL.
22
+ - `bin/ski.js`: `--verbose` is now a global flag with a toggle in the REPL.
23
+ - `bin/ski.js`: `--max`, `--max-size`, and `--max-args` arguments.
24
+ - `bin/ski.js search`: `--max-depth` and `--max-tries` options.
25
+ - New quest: affine extract from list.
26
+ - `zod` added as a dependency.
27
+
28
+ ### Fixed
29
+
30
+ - `infer()` now adheres to `maxSize`.
31
+ - Avoid adding context to parsed saved terms.
32
+ - `TraverseValue` signature now includes `void` so that `return undefined` in `traverse`/`fold` is no longer necessary.
33
+
34
+ ## [2.4.1] - 2026-03-06
35
+
36
+ ### Added
37
+
38
+ - expr.run({maxSize: number ?? 1_000_000}) to prevent runaway reductions (fixes #14).
39
+ - `ski.js infer <expression>` command to infer properties of an expression.
40
+
41
+ ### Fixed
42
+
43
+ - quests use shorter Y combinator form internally (#13)
44
+ - history in the playground is scrolled to the bottom.
45
+
8
46
  ## [2.4.0] - 2026-03-06
9
47
 
10
48
  ### BREAKING CHANGES
package/README.md CHANGED
@@ -99,12 +99,14 @@ npm install @dallaylaen/ski-interpreter
99
99
  * `--verbose` - Show all evaluation steps
100
100
  * Example: `ski file script.ski`
101
101
 
102
+ * **`infer <expression>`** - try to find equivalent lambda expression and display its properties if found.
103
+
102
104
  * **`extract <expression> <known term> ...`** -
103
105
  Replace parts of the expression that are equivalent to the known terms with the respective terms. Known terms must be normalizable.
104
106
 
105
107
  * **`search <expression> <known term> ...`** -
106
108
  Attempt to brute force an equivalent of the _expression_ using only the _known terms_.
107
- Only normalizing terms are currently supported.
109
+ Only normalizable terms are currently supported.
108
110
 
109
111
  * **`quest-lint <files...>`** - Validate quest definition files
110
112
  * `--solution <file>` - Load solutions from a JSON file for verification
@@ -150,7 +152,7 @@ const final = expr.run({max: 1000}); // { steps: 42, expr: '...' }
150
152
  const iterator = expr.walk();
151
153
 
152
154
  // applying expressions
153
- const result = expr.run({max: 1000}, arg1, arg2 ...);
155
+ const result = expr.run({max: 1000}, arg1, arg2, ...);
154
156
  // same sa
155
157
  expr.apply(arg1).apply(arg2).run();
156
158
  // or simply
package/bin/ski.js CHANGED
@@ -7,46 +7,67 @@ const { SKI } = require('../lib/ski-interpreter.cjs');
7
7
  const { Quest } = SKI;
8
8
  const { version } = require('../package.json');
9
9
 
10
+ const runOptions = {};
11
+ let format = {};
12
+ let verbose = false;
13
+
10
14
  const program = new Command();
11
15
 
12
16
  program
13
17
  .name('ski')
14
18
  .description('Simple Kombinator Interpreter - a combinatory logic & lambda calculus parser and interpreter')
15
- .version(version);
19
+ .version(version)
20
+ .option('-v, --verbose', 'Show all evaluation steps', () => { verbose = true; })
21
+ .option('--format <json>', 'Format for output expressions', setFormat)
22
+ .option('--max <number>', 'Limit computation steps', raw => {
23
+ const n = Number.parseInt(raw);
24
+ if (Number.isNaN(n) || n <= 0)
25
+ throw new Error('--max requires a positive integer');
26
+ runOptions.max = n;
27
+ })
28
+ .option('--max-size <number>', 'Limit expression\'s total footprint during computations', raw => {
29
+ const n = Number.parseInt(raw);
30
+ if (Number.isNaN(n) || n <= 0)
31
+ throw new Error('--max-size requires a positive integer');
32
+ runOptions.maxSize = n;
33
+ })
34
+ .option('--max-args <number>', 'Limit probed arguments when inferring terms', raw => {
35
+ const n = Number.parseInt(raw);
36
+ if (Number.isNaN(n) || n <= 0)
37
+ throw new Error('--max-args requires a positive integer');
38
+ runOptions.maxArgs = n;
39
+ });
16
40
 
17
41
  // REPL subcommand
18
42
  program
19
43
  .command('repl')
20
44
  .description('Start interactive REPL')
21
- .option('--verbose', 'Show all evaluation steps')
22
45
  .action((options) => {
23
- startRepl(options.verbose);
46
+ startRepl(options);
24
47
  });
25
48
 
26
49
  // Eval subcommand
27
50
  program
28
51
  .command('eval <expression>')
29
52
  .description('Evaluate a single expression')
30
- .option('--verbose', 'Show all evaluation steps')
31
53
  .action((expression, options) => {
32
- evaluateExpression(expression, options.verbose);
54
+ evaluateExpression(expression, options);
33
55
  });
34
56
 
35
57
  // File subcommand
36
58
  program
37
59
  .command('file <filepath>')
38
60
  .description('Evaluate expressions from a file')
39
- .option('--verbose', 'Show all evaluation steps')
40
61
  .action((filepath, options) => {
41
- evaluateFile(filepath, options.verbose);
62
+ evaluateFile(filepath);
42
63
  });
43
64
 
44
- // Search subcommand
65
+ // Infer subcommand
45
66
  program
46
- .command('search <target> <terms...>')
47
- .description('Search for an expression equivalent to target using known terms')
48
- .action((target, terms) => {
49
- searchExpression(target, terms);
67
+ .command('infer <expression>')
68
+ .description('Find a canonical form of the expression and its properties')
69
+ .action((expression) => {
70
+ inferExpression(expression);
50
71
  });
51
72
 
52
73
  // Extract subcommand
@@ -57,6 +78,14 @@ program
57
78
  extractExpression(target, terms);
58
79
  });
59
80
 
81
+ // Search subcommand
82
+ program
83
+ .command('search <target> <terms...>')
84
+ .description('Search for an expression equivalent to target using known terms')
85
+ .option('--max-depth <number>', 'Limit search depth', toInt('--max-depth'))
86
+ .option('--max-tries <number>', 'Limit total terms probed', toInt('--max-tries'))
87
+ .action(searchExpression);
88
+
60
89
  // Quest-check subcommand
61
90
  program
62
91
  .command('quest-lint <files...>')
@@ -72,9 +101,9 @@ program
72
101
  .parse(process.argv);
73
102
 
74
103
  if (!process.argv.slice(2).length)
75
- startRepl(false);
104
+ startRepl();
76
105
 
77
- function startRepl (verbose) {
106
+ function startRepl () {
78
107
  const readline = require('readline');
79
108
  const ski = new SKI();
80
109
 
@@ -92,7 +121,7 @@ function startRepl (verbose) {
92
121
  if (str.startsWith('!'))
93
122
  handleCommand(str, ski);
94
123
  else {
95
- processLine(str, ski, verbose, err => {
124
+ processLine(str, ski, err => {
96
125
  console.log('' + err);
97
126
  });
98
127
  }
@@ -108,19 +137,19 @@ function startRepl (verbose) {
108
137
  rl.prompt();
109
138
  }
110
139
 
111
- function evaluateExpression (expression, verbose) {
140
+ function evaluateExpression (expression) {
112
141
  const ski = new SKI();
113
- processLine(expression, ski, verbose, err => {
142
+ processLine(expression, ski, err => {
114
143
  console.error('' + err);
115
144
  process.exit(3);
116
145
  });
117
146
  }
118
147
 
119
- function evaluateFile (filepath, verbose) {
148
+ function evaluateFile (filepath) {
120
149
  const ski = new SKI();
121
150
  fs.readFile(filepath, 'utf8')
122
151
  .then(source => {
123
- processLine(source, ski, verbose, err => {
152
+ processLine(source, ski, err => {
124
153
  console.error('' + err);
125
154
  process.exit(3);
126
155
  });
@@ -131,7 +160,7 @@ function evaluateFile (filepath, verbose) {
131
160
  });
132
161
  }
133
162
 
134
- function processLine (source, ski, verbose, onErr) {
163
+ function processLine (source, ski, onErr) {
135
164
  if (!source.match(/\S/))
136
165
  return; // nothing to see here
137
166
 
@@ -141,12 +170,12 @@ function processLine (source, ski, verbose, onErr) {
141
170
  const isAlias = expr instanceof SKI.classes.Alias;
142
171
  const aliasName = isAlias ? expr.name : null;
143
172
 
144
- for (const state of expr.walk()) {
173
+ for (const state of expr.walk(runOptions)) {
145
174
  if (state.final)
146
175
  console.log(`// ${state.steps} step(s) in ${new Date() - t0}ms`);
147
176
 
148
177
  if (verbose || state.final)
149
- console.log('' + state.expr.format());
178
+ console.log(state.expr.format(format));
150
179
 
151
180
  if (state.final && isAlias && aliasName)
152
181
  ski.add(aliasName, state.expr);
@@ -156,6 +185,43 @@ function processLine (source, ski, verbose, onErr) {
156
185
  }
157
186
  }
158
187
 
188
+ function inferExpression (expression) {
189
+ const ski = new SKI();
190
+
191
+ const expr = ski.parse(expression);
192
+ const guess = expr.infer(runOptions);
193
+
194
+ if (guess.normal) {
195
+ displayInfer(guess);
196
+ return;
197
+ }
198
+ // hard case...
199
+ let steps = guess.steps;
200
+ const canon = expr.traverse(e => {
201
+ if (e === expr)
202
+ return; // already tried
203
+ const g = e.infer(runOptions);
204
+ steps += g.steps;
205
+ return g.expr;
206
+ });
207
+
208
+ displayInfer({ expr: canon, steps, normal: false, proper: false });
209
+ }
210
+
211
+ /**
212
+ *
213
+ * @param {TermInfo} guess
214
+ */
215
+ function displayInfer (guess) {
216
+ if (guess.expr)
217
+ console.log(guess.expr.format(format));
218
+
219
+ for (const key of ['normal', 'proper', 'arity', 'discard', 'duplicate', 'steps']) {
220
+ if (guess[key] !== undefined)
221
+ console.log(`// ${key}: ${guess[key]}`);
222
+ }
223
+ }
224
+
159
225
  async function questCheck (files, solutionFile) {
160
226
  try {
161
227
  // Load solutions if provided
@@ -220,11 +286,10 @@ async function questCheck (files, solutionFile) {
220
286
  }
221
287
  }
222
288
 
223
- function searchExpression (targetStr, termStrs) {
289
+ function searchExpression (targetStr, termStrs, options) {
224
290
  const ski = new SKI();
225
- const jar = {};
226
- const target = ski.parse(targetStr, { vars: jar });
227
- const seed = termStrs.map(s => ski.parse(s, { vars: jar }));
291
+ const target = ski.parse(targetStr);
292
+ const seed = termStrs.map(s => ski.parse(s));
228
293
 
229
294
  const { expr } = target.infer();
230
295
  if (!expr) {
@@ -232,7 +297,7 @@ function searchExpression (targetStr, termStrs) {
232
297
  process.exit(1);
233
298
  }
234
299
 
235
- const res = SKI.extras.search(seed, { tries: 10_000_000, depth: 100 }, (e, p) => {
300
+ const res = SKI.extras.search(seed, { tries: options.maxTries, depth: options.maxDepth }, (e, p) => {
236
301
  if (!p.expr)
237
302
  return -1;
238
303
  if (p.expr.equals(expr))
@@ -241,7 +306,7 @@ function searchExpression (targetStr, termStrs) {
241
306
  });
242
307
 
243
308
  if (res.expr) {
244
- console.log(`Found ${res.expr} after ${res.total} tries.`);
309
+ console.log(`Found ${res.expr.format(format)} after ${res.total} tries.`);
245
310
  process.exit(0);
246
311
  } else {
247
312
  console.error(`No equivalent expression found for ${target} after ${res.total} tries.`);
@@ -254,7 +319,7 @@ function extractExpression (targetStr, termStrs) {
254
319
  const expr = ski.parse(targetStr);
255
320
  const pairs = termStrs
256
321
  .map(s => ski.parse(s))
257
- .map(e => [e.infer().expr, e]);
322
+ .map(e => [e.infer(runOptions).expr, e]);
258
323
 
259
324
  const uncanonical = pairs.filter(pair => !pair[0]);
260
325
  if (uncanonical.length) {
@@ -264,25 +329,22 @@ function extractExpression (targetStr, termStrs) {
264
329
  }
265
330
 
266
331
  const replaced = expr.traverse(e => {
267
- const canon = e.infer().expr;
332
+ const canon = e.infer(runOptions).expr;
268
333
  if (canon) {
269
334
  for (const [lambda, term] of pairs) {
270
335
  if (canon.equals(lambda))
271
336
  return term;
272
337
  }
273
338
  }
274
- return null;
275
339
  });
276
340
 
277
- if (replaced)
278
- console.log(replaced.toString());
279
- else
341
+ console.log((replaced ?? expr).format(format));
342
+ if (!replaced)
280
343
  console.log('// unchanged');
281
344
  }
282
345
 
283
346
  function handleCommand (input, ski) {
284
- const parts = input.trim().split(/\s+/);
285
- const cmd = parts[0];
347
+ const [_, cmd, arg] = input.match(/^\s*(\S+)(?:\s+(.*\S))?\s*$/);
286
348
 
287
349
  const dispatch = {
288
350
  '!ls': () => {
@@ -296,9 +358,21 @@ function handleCommand (input, ski) {
296
358
  console.log(` ${name} ${term.props?.expr ?? '(native)'}`);
297
359
  }
298
360
  },
361
+ '!verbose': flag => {
362
+ verbose = !(['off', 'false', '0', '-'].includes(flag));
363
+ console.log('// verbose is ' + (verbose ? 'on' : 'off'));
364
+ },
365
+ '!format': options => {
366
+ if (options)
367
+ setFormat(options);
368
+ else
369
+ console.log('Format: ' + JSON.stringify(format, null, 2));
370
+ },
299
371
  '!help': () => {
300
372
  console.log('Available commands:');
301
373
  console.log(' !ls - List term inventory');
374
+ console.log(' !verbose [on|off] - Toggle verbose mode (show all evaluation steps)');
375
+ console.log(' !format [json] - Set or show output format options');
302
376
  console.log(' !help - Show this help message');
303
377
  },
304
378
  '': () => {
@@ -307,5 +381,22 @@ function handleCommand (input, ski) {
307
381
  }
308
382
  };
309
383
 
310
- (dispatch[cmd] || dispatch[''])(...parts.slice(1));
384
+ try {
385
+ (dispatch[cmd] || dispatch[''])(arg)
386
+ } catch (err) {
387
+ console.error(`Error executing command ${cmd}:`, err.message);
388
+ }
389
+ }
390
+
391
+ function setFormat (options) {
392
+ format = SKI.schemas.FormatOptions.parse(JSON.parse(options));
393
+ }
394
+
395
+ function toInt (comment) {
396
+ return function (str) {
397
+ const n = Number.parseInt(str);
398
+ if (Number.isNaN(n) || n <= 0)
399
+ throw new Error(comment + ' requires positive integer');
400
+ return n;
401
+ }
311
402
  }