eyeprolog 1.3.1 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.3.1",
6
+ "version": "1.3.2",
7
7
  "description": "EyeProlog turns facts and rules into answers and proofs.",
8
8
  "type": "module",
9
9
  "main": "./index.js",
package/src/repl.js CHANGED
@@ -55,7 +55,10 @@ export async function runRepl(engine, options = {}) {
55
55
  }
56
56
  const consultFiles = options.isoStrict ? null : consultDesignations(engine, goal);
57
57
  if (consultFiles != null) {
58
- for (const filename of consultFiles) sources.push(await readSource(filename));
58
+ for (const filename of consultFiles) {
59
+ const source = await readSource(filename);
60
+ replaceConsultedSource(sources, source);
61
+ }
59
62
  state = makeState(engine, sources, output, options, state, reader);
60
63
  runWithTerminalSignals(reader, () => state.solver.runInitializations());
61
64
  output.write(' true.\n');
@@ -447,10 +450,32 @@ function isHaltGoal(goal) {
447
450
  }
448
451
 
449
452
  function consultDesignations(engine, goal) {
453
+ // The traditional [file]. top-level shorthand and explicit consult/1 use
454
+ // the same resolver and modern reconsult semantics. Accept reconsult/1 as
455
+ // a compatibility alias as well; contemporary consult/1 already replaces
456
+ // clauses previously loaded from the same source.
450
457
  if (goal.type === 'atom' && goal.name === '[]') return [];
451
- if (goal.type !== 'compound' || goal.name !== '.' || goal.arity !== 2) return null;
452
- const items = engine.properListItems(goal, new engine.Env());
453
- if (items == null) return null;
458
+ if (goal.type === 'compound' && goal.name === '.' && goal.arity === 2) {
459
+ return consultListDesignations(engine, goal);
460
+ }
461
+ if (goal.type === 'compound' && ['consult', 'reconsult'].includes(goal.name) && goal.arity === 1) {
462
+ return consultArgumentDesignations(engine, goal.args[0]);
463
+ }
464
+ return null;
465
+ }
466
+
467
+ function consultArgumentDesignations(engine, term) {
468
+ if (term.type === 'var') throw new engine.PrologError('instantiation_error');
469
+ if (term.type === 'atom') return term.name === '[]' ? [] : [term.name];
470
+ if (term.type === 'compound' && term.name === '.' && term.arity === 2) {
471
+ return consultListDesignations(engine, term);
472
+ }
473
+ throw new engine.PrologError('type_error(atom)', term);
474
+ }
475
+
476
+ function consultListDesignations(engine, list) {
477
+ const items = engine.properListItems(list, new engine.Env());
478
+ if (items == null) throw new engine.PrologError('type_error(list)', list);
454
479
  return items.map((item) => {
455
480
  if (item.type === 'var') throw new engine.PrologError('instantiation_error');
456
481
  if (item.type !== 'atom') throw new engine.PrologError('type_error(atom)', item);
@@ -458,18 +483,43 @@ function consultDesignations(engine, goal) {
458
483
  });
459
484
  }
460
485
 
461
- async function readSource(designation) {
462
- let filename = path.resolve(designation);
463
- try {
464
- await fs.access(filename);
465
- } catch (error) {
466
- if (path.extname(filename)) throw error;
467
- filename += '.pl';
486
+ function replaceConsultedSource(sources, source) {
487
+ const index = sources.findIndex((existing) =>
488
+ source.consultPath != null && existing.consultPath === source.consultPath);
489
+ if (index >= 0) sources[index] = source;
490
+ else sources.push(source);
491
+ }
492
+
493
+ function missingSource(error) {
494
+ return error?.code === 'ENOENT' || error?.code === 'ENOTDIR';
495
+ }
496
+
497
+ async function resolvedConsultFilename(designation) {
498
+ const requested = path.resolve(designation);
499
+ // Long-standing Prolog consult convention: for an extensionless name, try
500
+ // the .pl source first and use the unsuffixed path only as a fallback.
501
+ if (!path.extname(requested)) {
502
+ const prologFile = `${requested}.pl`;
503
+ try {
504
+ await fs.access(prologFile);
505
+ return await fs.realpath(prologFile);
506
+ } catch (error) {
507
+ if (!missingSource(error)) throw error;
508
+ }
468
509
  }
510
+ await fs.access(requested);
511
+ return fs.realpath(requested);
512
+ }
513
+
514
+ async function readSource(designation) {
515
+ const filename = await resolvedConsultFilename(designation);
469
516
  return {
470
517
  text: await fs.readFile(filename, 'utf8'),
471
518
  filename: path.basename(filename),
472
519
  baseDir: path.dirname(filename),
520
+ // Keep host-only provenance so consulting the same resolved file again
521
+ // replaces its previous source instead of accumulating stale clauses.
522
+ consultPath: filename,
473
523
  };
474
524
  }
475
525
 
@@ -154,7 +154,7 @@ function* statisticsValueBuiltin({ solver, goal, env }) {
154
154
 
155
155
  if (entries == null) {
156
156
  if (key.type !== ATOM) throw new PrologError('type_error(atom)', key);
157
- return;
157
+ throw new PrologError('domain_error(statistics_key)', key);
158
158
  }
159
159
 
160
160
  for (const [name, value] of entries) {
@@ -1712,6 +1712,143 @@ c4 ?- call((!;1)).
1712
1712
  assertEqual(result.stderr, '', 'stderr');
1713
1713
  },
1714
1714
  },
1715
+ {
1716
+ name: 'REPL consult prefers .pl over an unsuffixed file (issue #47)',
1717
+ run: () => {
1718
+ const stem = path.join(tmp, `repl-consult-order-${++tmpCounter}`);
1719
+ fs.writeFileSync(stem, 'chosen(bare).\n');
1720
+ fs.writeFileSync(`${stem}.pl`, 'chosen(pl).\n');
1721
+ const result = runCli([], {
1722
+ input: `[${sourceAtom(stem)}].\nchosen(X).\nhalt.\n`,
1723
+ });
1724
+ assertEqual(result.status, 0, 'exit status');
1725
+ assertIncludes(result.stdout, 'X = pl.', 'consulted .pl source');
1726
+ assertNotIncludes(result.stdout, 'X = bare', 'unsuffixed source is fallback only');
1727
+ assertEqual(result.stderr, '', 'stderr');
1728
+
1729
+ const explicit = runCli([], {
1730
+ input: `consult(${sourceAtom(stem)}).\nchosen(X).\nhalt.\n`,
1731
+ });
1732
+ assertEqual(explicit.status, 0, 'consult/1 exit status');
1733
+ assertIncludes(explicit.stdout, 'X = pl.', 'consult/1 prefers .pl source');
1734
+ assertNotIncludes(explicit.stdout, 'X = bare', 'consult/1 does not prefer unsuffixed source');
1735
+ assertEqual(explicit.stderr, '', 'consult/1 stderr');
1736
+
1737
+ fs.rmSync(`${stem}.pl`);
1738
+ const fallback = runCli([], {
1739
+ input: `[${sourceAtom(stem)}].\nchosen(X).\nhalt.\n`,
1740
+ });
1741
+ assertEqual(fallback.status, 0, 'fallback exit status');
1742
+ assertIncludes(fallback.stdout, 'X = bare.', 'unsuffixed fallback source');
1743
+ assertEqual(fallback.stderr, '', 'fallback stderr');
1744
+ },
1745
+ },
1746
+ {
1747
+ name: 'REPL consultation replaces earlier clauses from the same file (issue #46)',
1748
+ run: () => {
1749
+ const filename = path.join(tmp, `repl-reconsult-${++tmpCounter}.pl`);
1750
+ fs.writeFileSync(filename, 'factum(f).\n');
1751
+ const harness = path.join(tmp, `repl-reconsult-harness-${++tmpCounter}.mjs`);
1752
+ const consultedAtom = sourceAtom(filename);
1753
+ fs.writeFileSync(harness, `
1754
+ import fs from 'node:fs';
1755
+ import process from 'node:process';
1756
+ import { spawn } from 'node:child_process';
1757
+
1758
+ const child = spawn(process.execPath, [${JSON.stringify(bin)}], { stdio: ['pipe', 'pipe', 'pipe'] });
1759
+ let stdout = '';
1760
+ let stderr = '';
1761
+ let advanced = false;
1762
+ let failed = false;
1763
+ const timer = setTimeout(() => {
1764
+ failed = true;
1765
+ child.kill();
1766
+ }, 5000);
1767
+
1768
+ child.stdout.setEncoding('utf8');
1769
+ child.stderr.setEncoding('utf8');
1770
+ child.stdout.on('data', (chunk) => {
1771
+ stdout += chunk;
1772
+ if (!advanced && stdout.includes('?- true.\\n?- ')) {
1773
+ advanced = true;
1774
+ fs.writeFileSync(${JSON.stringify(filename)}, 'factum(g).\\n');
1775
+ child.stdin.write(\`[${consultedAtom}].\\nfindall(F,factum(F),Fs).\\nhalt.\\n\`);
1776
+ }
1777
+ });
1778
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
1779
+ child.on('close', (code) => {
1780
+ clearTimeout(timer);
1781
+ process.stdout.write(stdout);
1782
+ process.stderr.write(stderr);
1783
+ process.exitCode = failed ? 99 : (code ?? 98);
1784
+ });
1785
+ child.stdin.write(\`[${consultedAtom}].\\n\`);
1786
+ `);
1787
+ const result = spawnSync(process.execPath, [harness], {
1788
+ cwd: packageRoot,
1789
+ encoding: 'utf8',
1790
+ timeout: 7000,
1791
+ });
1792
+ assertEqual(result.error?.code, undefined, 'reconsult harness timeout');
1793
+ assertEqual(result.status, 0, `exit status; stderr=${result.stderr}`);
1794
+ assertIncludes(result.stdout, 'Fs = \"g\".', 'reconsulted clauses');
1795
+ assertNotIncludes(result.stdout, 'f', 'stale clause removed');
1796
+ assertEqual(result.stderr, '', 'stderr');
1797
+ },
1798
+ },
1799
+ {
1800
+ name: 'REPL consult/1 has reconsult semantics',
1801
+ run: () => {
1802
+ const filename = path.join(tmp, `repl-consult-predicate-${++tmpCounter}.pl`);
1803
+ fs.writeFileSync(filename, 'factum(f).\n');
1804
+ const harness = path.join(tmp, `repl-consult-predicate-harness-${++tmpCounter}.mjs`);
1805
+ const consultedAtom = sourceAtom(filename);
1806
+ fs.writeFileSync(harness, `
1807
+ import fs from 'node:fs';
1808
+ import process from 'node:process';
1809
+ import { spawn } from 'node:child_process';
1810
+
1811
+ const child = spawn(process.execPath, [${JSON.stringify(bin)}], { stdio: ['pipe', 'pipe', 'pipe'] });
1812
+ let stdout = '';
1813
+ let stderr = '';
1814
+ let advanced = false;
1815
+ let failed = false;
1816
+ const timer = setTimeout(() => {
1817
+ failed = true;
1818
+ child.kill();
1819
+ }, 5000);
1820
+
1821
+ child.stdout.setEncoding('utf8');
1822
+ child.stderr.setEncoding('utf8');
1823
+ child.stdout.on('data', (chunk) => {
1824
+ stdout += chunk;
1825
+ if (!advanced && stdout.includes('?- true.\\n?- ')) {
1826
+ advanced = true;
1827
+ fs.writeFileSync(${JSON.stringify(filename)}, 'factum(g).\\n');
1828
+ child.stdin.write(\`consult(${consultedAtom}).\\nfindall(F,factum(F),Fs).\\nhalt.\\n\`);
1829
+ }
1830
+ });
1831
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
1832
+ child.on('close', (code) => {
1833
+ clearTimeout(timer);
1834
+ process.stdout.write(stdout);
1835
+ process.stderr.write(stderr);
1836
+ process.exitCode = failed ? 99 : (code ?? 98);
1837
+ });
1838
+ child.stdin.write(\`consult(${consultedAtom}).\\n\`);
1839
+ `);
1840
+ const result = spawnSync(process.execPath, [harness], {
1841
+ cwd: packageRoot,
1842
+ encoding: 'utf8',
1843
+ timeout: 7000,
1844
+ });
1845
+ assertEqual(result.error?.code, undefined, 'consult/1 reconsult harness timeout');
1846
+ assertEqual(result.status, 0, `exit status; stderr=${result.stderr}`);
1847
+ assertIncludes(result.stdout, 'Fs = "g".', 'consult/1 replaced earlier clauses');
1848
+ assertNotIncludes(result.stdout, 'f', 'consult/1 removed stale clause');
1849
+ assertEqual(result.stderr, '', 'stderr');
1850
+ },
1851
+ },
1715
1852
  {
1716
1853
  name: 'REPL preserves runtime unknown flag across consultation',
1717
1854
  run: () => {
@@ -2002,6 +2139,17 @@ c4 ?- call((!;1)).
2002
2139
  assertEqual(result.stderr, '', 'stderr');
2003
2140
  },
2004
2141
  },
2142
+ {
2143
+ name: 'statistics/2 rejects unknown keys instead of silently failing (issue #45)',
2144
+ run: () => {
2145
+ const result = runCli([], { input: 'statistics(nonsense, Value).\nhalt.\n' });
2146
+ assertEqual(result.status, 0, 'exit status');
2147
+ assertIncludes(result.stdout,
2148
+ 'error(domain_error(statistics_key, nonsense), eyeprolog).',
2149
+ 'statistics key error');
2150
+ assertEqual(result.stderr, '', 'stderr');
2151
+ },
2152
+ },
2005
2153
  {
2006
2154
  name: 'statistics predicates are excluded from strict ISO mode',
2007
2155
  run: () => {
@@ -4646,8 +4646,10 @@ where a live snapshot is useful. For example, a long-running loop can include
4646
4646
  immediately to the current output stream. `statistics/2` makes an individual
4647
4647
  value available to the program, for example
4648
4648
  `statistics(memory_guard_used_bytes, Used)`. With an unbound first argument it
4649
- enumerates the available statistic keys and values. These predicates are
4650
- EyeProlog observability extensions and are not available under `--iso-strict`.
4649
+ enumerates the available statistic keys and values. An atom that is not an
4650
+ available key raises `domain_error(statistics_key, Key)` rather than silently
4651
+ failing. These predicates are EyeProlog observability extensions and are not
4652
+ available under `--iso-strict`.
4651
4653
 
4652
4654
  Compare statistics only between runs with the same query, data, and observable
4653
4655
  answer contract. A faster program that silently loses answers is not an
@@ -6399,8 +6401,14 @@ not be a valid right operand of the displayed `=/2`, EyeProlog adds parentheses,
6399
6401
  for example `T = (a = b).` rather than the invalid `T = a = b.`. When an answer
6400
6402
  ends in a graphic token, the top level inserts layout before its terminating
6401
6403
  full stop so the two tokens cannot merge; for example `?- X = .* .` displays
6402
- `X = .* .`, not `X = .*.`. Use `[file].` or `['file.pl'].` to
6403
- consult local source, and `halt.` or `halt(Status).` to leave the top level.
6404
+ `X = .* .`, not `X = .*.`. Use `[file].`, `['file.pl'].`, or
6405
+ `consult(file).` to consult local source; `reconsult(file).` is accepted as a
6406
+ compatibility alias. Use `halt.` or `halt(Status).` to leave the top level.
6407
+ For an extensionless designation such as `[file].` or `consult(file).`, the
6408
+ top level tries `file.pl` before the unsuffixed `file`. Both the shorthand and
6409
+ `consult/1` have modern reconsult semantics: consulting the same resolved file
6410
+ again replaces its previous source, so clauses removed from the file do not
6411
+ remain active.
6404
6412
  When `read/1-2` or `read_term/2-3` actually reaches interactive
6405
6413
  `user_input`, the top level requests the next full-stop-terminated Prolog term
6406
6414
  with a `|: ` input prompt instead of treating the terminal stream as already