eyeprolog 1.3.26 → 1.3.27

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.26",
6
+ "version": "1.3.27",
7
7
  "description": "EyeProlog turns facts and rules into answers and proofs.",
8
8
  "type": "module",
9
9
  "main": "./index.js",
package/src/iso.js CHANGED
@@ -1322,6 +1322,7 @@ function* readTermBuiltin({ solver, goal, env }) {
1322
1322
  function defaultTermWriteOptions(mode) {
1323
1323
  if (mode === 'writeq') return { quoted: true, ignoreOps: false, numbervars: true, variableNames: new Map(), compact: true, operatorAtomsAsArgs: true, doubleQuotes: null };
1324
1324
  if (mode === 'canonical') return { quoted: true, ignoreOps: true, numbervars: false, variableNames: new Map(), compact: true, operatorAtomsAsArgs: true, doubleQuotes: null };
1325
+ if (mode === 'write_term') return { quoted: false, ignoreOps: false, numbervars: false, variableNames: new Map(), compact: true, operatorAtomsAsArgs: true, doubleQuotes: null };
1325
1326
  return { quoted: false, ignoreOps: false, numbervars: true, variableNames: new Map(), compact: true, operatorAtomsAsArgs: true, doubleQuotes: null };
1326
1327
  }
1327
1328
 
@@ -1361,7 +1362,7 @@ function writeVariableNames(value, env, option) {
1361
1362
  return names;
1362
1363
  }
1363
1364
 
1364
- function termWriteOptions(term, env, mode = 'write') {
1365
+ function termWriteOptions(term, env, mode = 'write_term') {
1365
1366
  const result = defaultTermWriteOptions(mode);
1366
1367
  for (const option of optionList(term, env)) {
1367
1368
  if (option.type === VAR) throw new PrologError('instantiation_error');
package/src/write.js CHANGED
@@ -143,6 +143,28 @@ function chooseOperator(term, table) {
143
143
  return null;
144
144
  }
145
145
 
146
+ function startsWithUnsignedNumber(term, env, table, options) {
147
+ const resolved = deref(term, env);
148
+ if (resolved.type === NUMBER) return !resolved.name.startsWith('-');
149
+ if (resolved.type !== COMPOUND || isCons(resolved)) return false;
150
+ if (options.numbervars && resolved.name === '$VAR' && resolved.arity === 1) {
151
+ const index = deref(resolved.args[0], env);
152
+ if (index.type === NUMBER && /^\d+$/.test(index.name) &&
153
+ writeNumberedVariable(Number(index.name)) != null) {
154
+ return false;
155
+ }
156
+ }
157
+ const definition = chooseOperator(resolved, table);
158
+ if (definition == null) return false;
159
+ if (definition.specifier === 'xf' || definition.specifier === 'yf') {
160
+ return startsWithUnsignedNumber(resolved.args[0], env, table, options);
161
+ }
162
+ if (definition.specifier === 'xfx' || definition.specifier === 'xfy' || definition.specifier === 'yfx') {
163
+ return startsWithUnsignedNumber(resolved.args[0], env, table, options);
164
+ }
165
+ return false;
166
+ }
167
+
146
168
  function printableReadVariableNames(term, env, explicit) {
147
169
  const names = new Map(explicit);
148
170
  const used = new Set(names.values());
@@ -286,7 +308,15 @@ function format(term, env, options, table, maxPriority = 1200, context = 'term')
286
308
  let text;
287
309
  if (specifier === 'fx' || specifier === 'fy') {
288
310
  const argumentPriority = specifier === 'fx' ? priority - 1 : priority;
289
- text = `${token} ${format(resolved.args[0], env, options, table, argumentPriority)}`;
311
+ // A separated `- 1` is still read as the negative number -1. When
312
+ // the source term is the unary -/1 compound, parenthesize a positive
313
+ // numeric-leading argument so writeq/write_term remain read-back safe
314
+ // (WG17 #135, #183, #215, #216, and #248).
315
+ if (resolved.name === '-' && startsWithUnsignedNumber(resolved.args[0], env, table, options)) {
316
+ text = `${token} (${format(resolved.args[0], env, options, table, 1200)})`;
317
+ } else {
318
+ text = `${token} ${format(resolved.args[0], env, options, table, argumentPriority)}`;
319
+ }
290
320
  } else if (specifier === 'xf' || specifier === 'yf') {
291
321
  let argumentPriority = specifier === 'xf' ? priority - 1 : priority;
292
322
  const childDefinition = chooseOperator(deref(resolved.args[0], env), table);
@@ -72,7 +72,7 @@ Run the Part 1 + Corrigenda strict-core processor gate:
72
72
  npm run test:iso-strict
73
73
  ```
74
74
 
75
- Run all vendored WG17 conformity matrices independently:
75
+ Run the vendored WG17 conformity matrix independently:
76
76
 
77
77
  ```sh
78
78
  npm run test:wg17
@@ -87,12 +87,15 @@ npm run test:wg17
87
87
  ```
88
88
 
89
89
  `wg17:upgrade` reconciles the upstream inventory by identifier: unchanged cases
90
- keep their reviewed exact outcomes, removed cases disappear, and new or
91
- semantically changed cases are executed directly against the upstream Codex
92
- expectation. This means `npm run test:wg17` can test an upgraded snapshot
93
- immediately without first copying EyeProlog's current behaviour into an
94
- expected result. Normal `npm test` remains offline and uses only the committed
95
- snapshot.
90
+ may keep their reviewed exact outcomes as an additional regression lock,
91
+ removed cases disappear, and new or semantically changed cases initially have
92
+ no local snapshot. **Every case is always checked independently against the
93
+ upstream Codex expectation**, so a reviewed EyeProlog outcome can never make a
94
+ non-conforming result pass (the failure mode that previously hid WG17 #227).
95
+ The runner follows the upstream `read(G), G` input protocol, including the
96
+ terminating newline, so stream-sensitive cases such as #270 and #271 exercise
97
+ the characters left after `read/1`. Normal `npm test` remains offline and uses
98
+ only the committed upstream snapshot.
96
99
 
97
100
  Summarize conformance coverage by category:
98
101
 
@@ -4,9 +4,10 @@ Source: [Conformity Testing I: Syntax](https://www.complang.tuwien.ac.at/ulrich/
4
4
  Upstream inventory checked: 2026-08-16
5
5
 
6
6
  This ledger counts an upstream case when its WG17 identifier, query, and
7
- expected ISO disposition are stored in the offline executable matrix. Existing
8
- cases may pin an exact reviewed EyeProlog outcome; newly upgraded cases are
9
- executed directly against the upstream Codex expectation.
7
+ expected ISO disposition are stored in the offline executable matrix. Every
8
+ case is executed against the upstream Codex expectation. Reviewed exact
9
+ EyeProlog outcomes are additional regression locks and can never override the
10
+ upstream assertion.
10
11
 
11
12
  ## Current standing
12
13
 
@@ -19,8 +20,9 @@ executed directly against the upstream Codex expectation.
19
20
 
20
21
  The matrix runs in strict ISO stream-reader mode as part of `npm test`. The
21
22
  3 upstream `waits` cases are checked through EyeProlog's interactive input
22
- hook. 1 case uses the upstream Codex expectation directly; the remaining
23
- 365 cases retain exact stored outcomes for stronger regression checking.
23
+ hook. All 366 executable cases are independently checked against the
24
+ upstream Codex expectation. 365 cases additionally retain exact reviewed
25
+ outcomes for stronger regression checking; 1 case currently relies on the upstream assertion alone.
24
26
 
25
27
  ## Traceable evidence
26
28
 
@@ -1308,7 +1308,7 @@
1308
1308
  "type": "success",
1309
1309
  "stages": [
1310
1310
  {
1311
- "output": "- 1",
1311
+ "output": "- (1)",
1312
1312
  "variables": "[]"
1313
1313
  }
1314
1314
  ]
@@ -1340,7 +1340,7 @@
1340
1340
  "type": "success",
1341
1341
  "stages": [
1342
1342
  {
1343
- "output": "- 1 ^ 2",
1343
+ "output": "- (1 ^ 2)",
1344
1344
  "variables": "[]"
1345
1345
  }
1346
1346
  ]
@@ -1532,7 +1532,7 @@
1532
1532
  "type": "success",
1533
1533
  "stages": [
1534
1534
  {
1535
- "output": "- - 1",
1535
+ "output": "- - (1)",
1536
1536
  "variables": "[]"
1537
1537
  }
1538
1538
  ]
@@ -1552,7 +1552,7 @@
1552
1552
  "variables": "[]"
1553
1553
  },
1554
1554
  {
1555
- "output": "- 1 ~ 2 ~ 3",
1555
+ "output": "- (1 ~ 2 ~ 3)",
1556
1556
  "variables": "[]"
1557
1557
  }
1558
1558
  ]
@@ -1572,7 +1572,7 @@
1572
1572
  "variables": "[]"
1573
1573
  },
1574
1574
  {
1575
- "output": "- 1 ~ 2",
1575
+ "output": "- (1 ~ 2)",
1576
1576
  "variables": "[]"
1577
1577
  }
1578
1578
  ]
@@ -3162,7 +3162,7 @@
3162
3162
  "type": "success",
3163
3163
  "stages": [
3164
3164
  {
3165
- "output": "A",
3165
+ "output": "$VAR(0)",
3166
3166
  "variables": "[]"
3167
3167
  }
3168
3168
  ]
@@ -5092,7 +5092,7 @@
5092
5092
  "type": "success",
5093
5093
  "stages": [
5094
5094
  {
5095
- "output": "- (1 * 2) ^ 3",
5095
+ "output": "- ((1 * 2) ^ 3)",
5096
5096
  "variables": "[]"
5097
5097
  }
5098
5098
  ]
File without changes
@@ -54,7 +54,7 @@ import { proofExamples } from './run-examples.mjs';
54
54
  import { goalsFromSource } from './goal-metadata.mjs';
55
55
  import { renderWg17SyntaxStatus } from '../tools/report-wg17-syntax-coverage.mjs';
56
56
  import { parseWg17SyntaxTable } from '../tools/upgrade-wg17.mjs';
57
- import { matchesUpstreamExpectation } from './run-wg17.mjs';
57
+ import { executeWg17Item, matchesUpstreamExpectation, readWg17SyntaxFixture } from './run-wg17.mjs';
58
58
 
59
59
  const testRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)));
60
60
  const packageRoot = path.resolve(testRoot, '..');
@@ -3236,7 +3236,7 @@ function documentationSyncCases() {
3236
3236
  },
3237
3237
  },
3238
3238
  {
3239
- name: 'WG17 direct upstream assertions are executable without local outcomes',
3239
+ name: 'WG17 upstream expectations independently validate reviewed outcomes',
3240
3240
  run: () => {
3241
3241
  assertEqual(matchesUpstreamExpectation('succeeds', { type: 'success', stages: [] }), true, 'succeeds');
3242
3242
  assertEqual(matchesUpstreamExpectation('fails', { type: 'failure' }), true, 'fails');
@@ -3251,6 +3251,39 @@ function documentationSyncCases() {
3251
3251
  true,
3252
3252
  'observable output',
3253
3253
  );
3254
+ const repeated = {
3255
+ id: 227, input: 'write_canonical(B+B).',
3256
+ outcome: { type: 'success', stages: [{ output: '+(_A,_A)', variables: "['B' = B]" }] },
3257
+ };
3258
+ assertEqual(
3259
+ matchesUpstreamExpectation('e.g. +(_1,_1)', repeated.outcome, repeated),
3260
+ true,
3261
+ 'anonymous spelling accepted',
3262
+ );
3263
+ assertEqual(
3264
+ matchesUpstreamExpectation(
3265
+ 'e.g. +(_1,_1)',
3266
+ { type: 'success', stages: [{ output: '+(B,B)', variables: "['B' = B]" }] },
3267
+ repeated,
3268
+ ),
3269
+ false,
3270
+ 'named-variable spelling rejected',
3271
+ );
3272
+ },
3273
+ },
3274
+ {
3275
+ name: 'WG17 stream-sensitive cases #270 and #271 follow the upstream input protocol',
3276
+ run: () => {
3277
+ const fixture = readWg17SyntaxFixture();
3278
+ const byId = new Map(fixture.cases.map((item) => [item.id, item]));
3279
+ for (const [id, expected] of [[270, "C = ' '"], [271, "C = '%'"]]) {
3280
+ const item = byId.get(id);
3281
+ if (item == null) throw new Error(`missing WG17 #${id}`);
3282
+ assertEqual(item.expected, expected, `WG17 #${id} upstream expectation`);
3283
+ const actual = executeWg17Item(item);
3284
+ assertEqual(matchesUpstreamExpectation(item.expected, actual, item), true, `WG17 #${id} result`);
3285
+ assertEqual(JSON.stringify(actual), JSON.stringify(item.outcome), `WG17 #${id} reviewed outcome`);
3286
+ }
3254
3287
  },
3255
3288
  },
3256
3289
  {
package/test/run-wg17.mjs CHANGED
@@ -1,13 +1,16 @@
1
1
  #!/usr/bin/env node
2
- // Aggregate all vendored WG17 conformity suites behind one stable entry point.
3
- // The current upstream conformity table is syntax-focused; additional WG17
4
- // suites can be added here without changing npm/CI commands.
2
+ // Offline execution of the vendored WG17 conformity-testing syntax matrix.
3
+ // Every case is checked against the upstream Codex expectation. Reviewed
4
+ // exact EyeProlog outcomes are an additional regression lock, never a
5
+ // replacement for the upstream assertion.
5
6
  import fs from 'node:fs';
6
7
  import path from 'node:path';
7
8
  import { fileURLToPath } from 'node:url';
8
9
  import {
9
10
  Env, Program, Solver, parseGoalText, run,
10
11
  } from '../src/index.js';
12
+ import { parseTermText } from '../src/parser.js';
13
+ import { variantTerms } from '../src/term.js';
11
14
  import { TestReporter, isMainModule } from './test-style.mjs';
12
15
 
13
16
  const testRoot = path.dirname(fileURLToPath(import.meta.url));
@@ -32,6 +35,9 @@ function capturedStages(stdout) {
32
35
 
33
36
  function executeFinite(item) {
34
37
  try {
38
+ // Match the upstream protocol: the Query cell plus its terminating newline
39
+ // is input to read(G), G (and subsequent read/call stages when present).
40
+ // This is essential for stream-sensitive cases such as #270 and #271.
35
41
  const result = run('', {
36
42
  isoStrict: true,
37
43
  goal: runnerStage(1, item.readCount ?? 16),
@@ -48,7 +54,7 @@ function executeWait(item) {
48
54
  const program = Program.parse('', { isoStrict: true });
49
55
  const solver = new Solver(program, {
50
56
  isoStrict: true,
51
- ioOptions: { input: item.input },
57
+ ioOptions: { input: `${item.input}\n` },
52
58
  });
53
59
  const stream = solver.io.resolve('user_input');
54
60
  let requests = 0;
@@ -63,32 +69,203 @@ function executeWait(item) {
63
69
  try {
64
70
  [...solver.solve([goal], new Env(), 0)];
65
71
  } catch (_) {
66
- // Returning null from the hook models EOF after EyeProlog has asked the
67
- // interactive source for the continuation that the upstream case awaits.
72
+ // Returning null models EOF only after EyeProlog has requested the extra
73
+ // input that the upstream case classifies as "waits".
68
74
  }
69
75
  return requests === 1 ? { type: 'waits' } : { type: 'did_not_wait', requests };
70
76
  }
71
77
 
72
- function canonicalUpstreamExpected(expected) {
73
- return String(expected)
78
+ function presentationText(value) {
79
+ return String(value ?? '')
80
+ .replace(/&sup[23];/gi, '')
74
81
  .replace(/[²³°]/g, '')
75
82
  .replace(/\u00a0/g, ' ')
76
- .replace(/[ \t\n]+/g, ' ')
83
+ .replace(/\r\n?/g, '\n')
77
84
  .trim();
78
85
  }
79
86
 
80
- function observableOutput(actual) {
81
- if (actual.type !== 'success') return null;
82
- return actual.stages.map(({ output }) => output).join('');
87
+ function canonicalUpstreamExpected(expected) {
88
+ return presentationText(expected).replace(/[ \t\n]+/g, ' ').trim();
89
+ }
90
+
91
+ function bindingAnswer(text) {
92
+ let value = String(text ?? '').trim();
93
+ if (value === '[]') return '';
94
+ if (value.startsWith('[') && value.endsWith(']')) value = value.slice(1, -1);
95
+ return value.replace(/'([A-Z_][A-Za-z0-9_]*)'\s*=/g, '$1 =');
96
+ }
97
+
98
+ function observableCandidates(actual) {
99
+ if (actual.type !== 'success') return [];
100
+ const outputs = actual.stages.map(({ output }) => output ?? '').filter(Boolean);
101
+ const bindings = actual.stages.map(({ variables }) => bindingAnswer(variables)).filter(Boolean);
102
+ const candidates = new Set([...outputs, ...bindings]);
103
+ if (outputs.length > 0) {
104
+ candidates.add(outputs.join(''));
105
+ candidates.add(outputs.join(' '));
106
+ }
107
+ if (bindings.length > 0) candidates.add(bindings.join(', '));
108
+ if (outputs.length > 0 && bindings.length > 0) {
109
+ candidates.add([...outputs, ...bindings].join(' '));
110
+ candidates.add(`${outputs.join('')} ${bindings.join(', ')}`);
111
+ }
112
+ return [...candidates].filter(Boolean);
113
+ }
114
+
115
+ function stripLayoutOutsideQuotes(text) {
116
+ const source = presentationText(text);
117
+ let output = '';
118
+ let quote = null;
119
+ for (let index = 0; index < source.length; index++) {
120
+ const ch = source[index];
121
+ if (quote != null) {
122
+ output += ch;
123
+ if (ch === '\\' && index + 1 < source.length) {
124
+ output += source[++index];
125
+ continue;
126
+ }
127
+ if (ch === quote) {
128
+ if (source[index + 1] === quote) output += source[++index];
129
+ else quote = null;
130
+ }
131
+ continue;
132
+ }
133
+ if (ch === "'" || ch === '"' || ch === '`') {
134
+ quote = ch;
135
+ output += ch;
136
+ } else if (!/\s/.test(ch)) {
137
+ output += ch;
138
+ }
139
+ }
140
+ return output.replace(/\.$/, '').replace(/([eE])\+(?=\d)/g, '$1');
141
+ }
142
+
143
+ function normalizeExampleVariables(text) {
144
+ const names = new Map();
145
+ let next = 0;
146
+ return String(text).replace(/(?<![A-Za-z0-9_])_[A-Za-z0-9]+/g, (name) => {
147
+ if (!names.has(name)) names.set(name, `_V${++next}`);
148
+ return names.get(name);
149
+ });
150
+ }
151
+
152
+ function unquoteAtomText(text) {
153
+ const source = text.trim();
154
+ if (!(source.startsWith("'") && source.endsWith("'"))) return source;
155
+ let output = '';
156
+ for (let index = 1; index < source.length - 1; index++) {
157
+ const ch = source[index];
158
+ if (ch === "'" && source[index + 1] === "'") {
159
+ output += "'";
160
+ index++;
161
+ continue;
162
+ }
163
+ if (ch === '\\' && index + 1 < source.length - 1) {
164
+ const escaped = source[++index];
165
+ const symbolic = { a: '\x07', b: '\b', f: '\f', n: '\n', r: '\r', t: '\t', v: '\v' };
166
+ output += symbolic[escaped] ?? escaped;
167
+ continue;
168
+ }
169
+ output += ch;
170
+ }
171
+ return output;
172
+ }
173
+
174
+ function splitOperatorNames(text) {
175
+ const source = text.trim();
176
+ if (!source.startsWith('[')) return [unquoteAtomText(source)];
177
+ const body = source.slice(1, -1);
178
+ const names = [];
179
+ let quote = false;
180
+ let start = 0;
181
+ for (let index = 0; index <= body.length; index++) {
182
+ const ch = body[index];
183
+ if (quote) {
184
+ if (ch === "'" && body[index + 1] === "'") index++;
185
+ else if (ch === "'") quote = false;
186
+ continue;
187
+ }
188
+ if (ch === "'") {
189
+ quote = true;
190
+ continue;
191
+ }
192
+ if (ch === ',' || index === body.length) {
193
+ names.push(unquoteAtomText(body.slice(start, index)));
194
+ start = index + 1;
195
+ }
196
+ }
197
+ return names.filter(Boolean);
198
+ }
199
+
200
+ function operatorDefinitionsFromInput(input) {
201
+ const definitions = [];
202
+ const pattern = /\bop\(\s*(\d+)\s*,\s*(fx|fy|xf|yf|xfx|xfy|yfx)\s*,\s*(\[[^\]]*\]|'(?:''|[^'])*'|[^)\s,]+)\s*\)/g;
203
+ for (const match of String(input ?? '').matchAll(pattern)) {
204
+ for (const name of splitOperatorNames(match[3])) {
205
+ definitions.push([Number(match[1]), match[2], name]);
206
+ }
207
+ }
208
+ return definitions;
209
+ }
210
+
211
+ function termEquivalent(expected, actual, item) {
212
+ try {
213
+ const operatorDefinitions = operatorDefinitionsFromInput(item?.input);
214
+ const options = { isoStrict: true, operatorDefinitions };
215
+ const left = parseTermText(`${presentationText(expected).replace(/\.$/, '')}.`, options);
216
+ const right = parseTermText(`${presentationText(actual).replace(/\.$/, '')}.`, options);
217
+ return variantTerms(left, new Env(), right, new Env());
218
+ } catch (_) {
219
+ return false;
220
+ }
221
+ }
222
+
223
+ function abbreviatedErrorMatches(expected, actual) {
224
+ if (actual.type !== 'error') return false;
225
+ const formal = actual.formal ?? '';
226
+ const patterns = [
227
+ [/p\._e\./i, 'permission_error'],
228
+ [/d\._e\./i, 'domain_error'],
229
+ [/ex\._e\./i, 'existence_error'],
230
+ [/rep(?:r)?\._e\.|repr\.\s*err\./i, 'representation_error'],
231
+ ];
232
+ return patterns.some(([pattern, prefix]) => pattern.test(expected) && formal.startsWith(prefix));
83
233
  }
84
234
 
85
- export function matchesUpstreamExpectation(expectedText, actual) {
235
+ function textExpectationMatches(expectedText, actual, item, example = false) {
236
+ const candidates = observableCandidates(actual);
237
+ if (candidates.length === 0) return false;
238
+ let expected = presentationText(expectedText);
239
+ if (example) expected = expected.replace(/^e\.g\.\s*/i, '');
240
+
241
+ for (const candidate of candidates) {
242
+ let left = expected;
243
+ let right = candidate;
244
+ if (example && /(?<![A-Za-z0-9_])_[A-Za-z0-9]+/.test(left)) {
245
+ // ISO 7.10.5 requires the generated spelling to be an anonymous-variable
246
+ // token. Normalise only the choice of suffix/name, not the leading `_`.
247
+ if (!/(?<![A-Za-z0-9_])_[A-Za-z0-9]+/.test(right)) continue;
248
+ left = normalizeExampleVariables(left);
249
+ right = normalizeExampleVariables(right);
250
+ }
251
+ if (stripLayoutOutsideQuotes(left) === stripLayoutOutsideQuotes(right)) return true;
252
+ if (termEquivalent(left, right, item)) return true;
253
+ if (example) {
254
+ const expectedNumber = Number(left);
255
+ const actualNumber = Number(right);
256
+ if (Number.isFinite(expectedNumber) && Number.isFinite(actualNumber) &&
257
+ Object.is(expectedNumber, actualNumber)) return true;
258
+ }
259
+ }
260
+ return false;
261
+ }
262
+
263
+ export function matchesUpstreamExpectation(expectedText, actual, item = {}) {
86
264
  const expected = canonicalUpstreamExpected(expectedText);
87
265
 
88
266
  if (/^waits$/i.test(expected)) return actual.type === 'waits';
89
267
  if (/^succeeds(?:\b|$)/i.test(expected)) return actual.type === 'success';
90
268
  if (/^fails(?:\b|$)/i.test(expected)) return actual.type === 'failure';
91
-
92
269
  if (/^syntax\s*err\.?$/i.test(expected)) {
93
270
  return actual.type === 'error' && /^syntax_error\(/.test(actual.formal ?? '');
94
271
  }
@@ -96,26 +273,24 @@ export function matchesUpstreamExpectation(expectedText, actual) {
96
273
  return actual.type === 'error' && /^representation_error\(/.test(actual.formal ?? '');
97
274
  }
98
275
  if (/^syntax\/repr\.\s*err\.?$/i.test(expected)) {
99
- return actual.type === 'error' &&
100
- /^(?:syntax_error|representation_error)\(/.test(actual.formal ?? '');
276
+ return actual.type === 'error' && /^(?:syntax_error|representation_error)\(/.test(actual.formal ?? '');
101
277
  }
102
278
  if (/^syntax\s*err\.\/waits$/i.test(expected)) {
103
279
  return actual.type === 'waits' ||
104
280
  (actual.type === 'error' && /^syntax_error\(/.test(actual.formal ?? ''));
105
281
  }
282
+ if (abbreviatedErrorMatches(expected, actual)) return true;
106
283
 
107
- // Most new WG17 rows are observable write/read examples. For those, the
108
- // Codex cell itself is the expected output, so no hand-written local
109
- // outcome is needed before the case can be executed.
110
- const output = observableOutput(actual);
111
- if (output != null && canonicalUpstreamExpected(output) === expected) return true;
112
-
113
- return false;
284
+ if (/\s+or\s+/i.test(expected)) {
285
+ return expected.split(/\s+or\s+/i)
286
+ .some((alternative) => matchesUpstreamExpectation(alternative, actual, item));
287
+ }
288
+ if (/^e\.g\.\s*/i.test(expected)) return textExpectationMatches(expected, actual, item, true);
289
+ return textExpectationMatches(expected, actual, item, false);
114
290
  }
115
291
 
116
292
  function usesWaitMatcher(expectedText) {
117
- const expected = canonicalUpstreamExpected(expectedText);
118
- return /^waits$/i.test(expected);
293
+ return /^waits$/i.test(canonicalUpstreamExpected(expectedText));
119
294
  }
120
295
 
121
296
  function compactTestText(value, maximum) {
@@ -135,30 +310,34 @@ export function wg17TestDescription(item) {
135
310
  return `#${item.id} ${query} -> ${expected}`;
136
311
  }
137
312
 
313
+ export function executeWg17Item(item) {
314
+ return usesWaitMatcher(item.expected) || item.outcome?.type === 'waits'
315
+ ? executeWait(item)
316
+ : executeFinite(item);
317
+ }
318
+
138
319
  function assertOutcome(item) {
139
- if (item.outcome != null) {
140
- const actual = item.outcome.type === 'waits' ? executeWait(item) : executeFinite(item);
141
- if (JSON.stringify(actual) !== JSON.stringify(item.outcome)) {
142
- throw new Error(
143
- `WG17 #${item.id} (${item.expected})\n` +
144
- `expected ${JSON.stringify(item.outcome)}\n` +
145
- `actual ${JSON.stringify(actual)}`,
146
- );
147
- }
148
- return;
149
- }
320
+ const actual = executeWg17Item(item);
150
321
 
151
- const actual = usesWaitMatcher(item.expected) ? executeWait(item) : executeFinite(item);
152
- if (!matchesUpstreamExpectation(item.expected, actual)) {
322
+ if (!matchesUpstreamExpectation(item.expected, actual, item)) {
153
323
  throw new Error(
154
324
  `WG17 #${item.id} (${item.expected})\n` +
155
- `upstream expectation did not match\n` +
325
+ `upstream Codex expectation did not match\n` +
156
326
  `actual ${JSON.stringify(actual)}`,
157
327
  );
158
328
  }
329
+
330
+ if (item.outcome != null && JSON.stringify(actual) !== JSON.stringify(item.outcome)) {
331
+ throw new Error(
332
+ `WG17 #${item.id} (${item.expected})\n` +
333
+ `reviewed regression outcome changed\n` +
334
+ `expected ${JSON.stringify(item.outcome)}\n` +
335
+ `actual ${JSON.stringify(actual)}`,
336
+ );
337
+ }
159
338
  }
160
339
 
161
- function readWg17SyntaxFixture() {
340
+ export function readWg17SyntaxFixture() {
162
341
  const fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
163
342
  if (!Array.isArray(fixture.cases) || fixture.cases.length === 0) {
164
343
  throw new Error('WG17 syntax fixture has no cases');
@@ -168,6 +347,16 @@ function readWg17SyntaxFixture() {
168
347
  if (!Number.isInteger(item.id) || ids.has(item.id)) {
169
348
  throw new Error(`invalid or duplicate WG17 syntax id #${item.id}`);
170
349
  }
350
+ if (typeof item.query !== 'string' || typeof item.input !== 'string' || typeof item.expected !== 'string') {
351
+ throw new Error(`incomplete WG17 syntax fixture row #${item.id}`);
352
+ }
353
+ if (item.outcome != null && !matchesUpstreamExpectation(item.expected, item.outcome, item)) {
354
+ throw new Error(
355
+ `WG17 #${item.id} reviewed outcome contradicts upstream Codex expectation\n` +
356
+ `upstream ${JSON.stringify(item.expected)}\n` +
357
+ `outcome ${JSON.stringify(item.outcome)}`,
358
+ );
359
+ }
171
360
  ids.add(item.id);
172
361
  }
173
362
  return fixture;
@@ -183,10 +372,8 @@ function runWg17Syntax(reporter = new TestReporter()) {
183
372
  reporter.sectionTotal('WG17 syntax');
184
373
  }
185
374
 
186
- const suites = [runWg17Syntax];
187
-
188
375
  export function runWg17(reporter = new TestReporter()) {
189
- for (const runSuite of suites) runSuite(reporter);
376
+ runWg17Syntax(reporter);
190
377
  }
191
378
 
192
379
  if (isMainModule(import.meta.url)) {
@@ -92,9 +92,10 @@ Source: [Conformity Testing I: Syntax](${manifest.source})
92
92
  Upstream inventory checked: ${manifest.checkedOn}
93
93
 
94
94
  This ledger counts an upstream case when its WG17 identifier, query, and
95
- expected ISO disposition are stored in the offline executable matrix. Existing
96
- cases may pin an exact reviewed EyeProlog outcome; newly upgraded cases are
97
- executed directly against the upstream Codex expectation.
95
+ expected ISO disposition are stored in the offline executable matrix. Every
96
+ case is executed against the upstream Codex expectation. Reviewed exact
97
+ EyeProlog outcomes are additional regression locks and can never override the
98
+ upstream assertion.
98
99
 
99
100
  ## Current standing
100
101
 
@@ -107,8 +108,9 @@ executed directly against the upstream Codex expectation.
107
108
 
108
109
  The matrix runs in strict ISO stream-reader mode as part of \`npm test\`. The
109
110
  ${waits} upstream \`waits\` case${waits === 1 ? '' : 's'} ${waits === 1 ? 'is' : 'are'} checked through EyeProlog's interactive input
110
- hook. ${direct} case${direct === 1 ? '' : 's'} use${direct === 1 ? 's' : ''} the upstream Codex expectation directly; the remaining
111
- ${coveredIds.length - direct} case${coveredIds.length - direct === 1 ? '' : 's'} retain exact stored outcomes for stronger regression checking.
111
+ hook. All ${coveredIds.length} executable cases are independently checked against the
112
+ upstream Codex expectation. ${coveredIds.length - direct} case${coveredIds.length - direct === 1 ? '' : 's'} additionally retain exact reviewed
113
+ outcomes for stronger regression checking; ${direct} case${direct === 1 ? '' : 's'} currently ${direct === 1 ? 'relies' : 'rely'} on the upstream assertion alone.
112
114
 
113
115
  ## Traceable evidence
114
116
 
@@ -364,7 +364,7 @@ function printHelp() {
364
364
  process.stdout.write(`Usage: npm run wg17:upgrade -- [--check] [--source URL_OR_FILE]\n\n` +
365
365
  `Refreshes the vendored WG17 conformity tests from the TU Wien table.\n` +
366
366
  `New or changed rows are executable immediately against the upstream\n` +
367
- `Codex expectation; existing reviewed exact outcomes remain pinned.\n`);
367
+ `Codex expectation; existing reviewed exact outcomes remain pinned only as additional regression checks.\n`);
368
368
  }
369
369
 
370
370
  export async function upgradeWg17({ check = false, source = syntaxSource } = {}) {
@@ -1,106 +0,0 @@
1
- #!/usr/bin/env node
2
- // Offline, one-by-one execution of the 366 active WG17 syntax cases. The
3
- // fixture is a dated snapshot of the public conformity-testing table, so the
4
- // release gate does not depend on the network or another Prolog system.
5
- import fs from 'node:fs';
6
- import path from 'node:path';
7
- import { fileURLToPath } from 'node:url';
8
- import {
9
- Env, Program, Solver, parseGoalText, run,
10
- } from '../src/index.js';
11
- import { TestReporter, isMainModule } from './test-style.mjs';
12
-
13
- const testRoot = path.dirname(fileURLToPath(import.meta.url));
14
- const fixturePath = path.join(testRoot, 'conformance', 'wg17-syntax-cases.json');
15
-
16
- function runnerStage(index, maximum) {
17
- if (index > maximum) return `write('\\n<WG17-COMPLETE>')`;
18
- return `read_term(G${index}, [variable_names(V${index})]), ` +
19
- `(G${index} == end_of_file -> write('\\n<WG17-COMPLETE>') ; (` +
20
- `write('\\n<WG17-BEGIN-${index}>'), call(G${index}), ` +
21
- `write('<WG17-VARS>'), writeq(V${index}), write('<WG17-END>'), ` +
22
- `${runnerStage(index + 1, maximum)}))`;
23
- }
24
-
25
- function capturedStages(stdout) {
26
- const complete = stdout.indexOf('<WG17-COMPLETE>');
27
- if (complete < 0) return null;
28
- const captured = stdout.slice(0, complete);
29
- return [...captured.matchAll(/<WG17-BEGIN-(\d+)>([\s\S]*?)<WG17-VARS>([\s\S]*?)<WG17-END>/g)]
30
- .map((match) => ({ output: match[2], variables: match[3] }));
31
- }
32
-
33
- function executeFinite(item) {
34
- try {
35
- const result = run('', {
36
- isoStrict: true,
37
- goal: runnerStage(1, item.readCount),
38
- ioOptions: { input: `${item.input}\n` },
39
- });
40
- const stages = capturedStages(result.stdout);
41
- return stages == null ? { type: 'failure' } : { type: 'success', stages };
42
- } catch (error) {
43
- return { type: 'error', formal: error?.formal ?? null };
44
- }
45
- }
46
-
47
- function executeWait(item) {
48
- const program = Program.parse('', { isoStrict: true });
49
- const solver = new Solver(program, {
50
- isoStrict: true,
51
- ioOptions: { input: item.input },
52
- });
53
- const stream = solver.io.resolve('user_input');
54
- let requests = 0;
55
- stream.interactiveReadTerm = () => {
56
- requests++;
57
- return null;
58
- };
59
- const goal = parseGoalText('read_term(G, [])', {
60
- isoStrict: true,
61
- operatorDefinitions: [...program.operators.values()],
62
- });
63
- try {
64
- [...solver.solve([goal], new Env(), 0)];
65
- } catch (_) {
66
- // Returning null from the hook models EOF after EyeProlog has asked the
67
- // interactive source for the continuation that the upstream case awaits.
68
- }
69
- return requests === 1 ? { type: 'waits' } : { type: 'did_not_wait', requests };
70
- }
71
-
72
- function assertOutcome(item) {
73
- const actual = item.outcome.type === 'waits' ? executeWait(item) : executeFinite(item);
74
- if (JSON.stringify(actual) !== JSON.stringify(item.outcome)) {
75
- throw new Error(
76
- `WG17 #${item.id} (${item.expected})\n` +
77
- `expected ${JSON.stringify(item.outcome)}\n` +
78
- `actual ${JSON.stringify(actual)}`,
79
- );
80
- }
81
- }
82
-
83
- export function runWg17Syntax(reporter = new TestReporter()) {
84
- const fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
85
- if (fixture.cases.length !== 366) {
86
- throw new Error(`WG17 fixture has ${fixture.cases.length} cases instead of 366`);
87
- }
88
- const ids = new Set(fixture.cases.map(({ id }) => id));
89
- if (ids.size !== 366 || ids.has(20) || ids.has(273)) {
90
- throw new Error('WG17 fixture identifiers do not match the active upstream inventory');
91
- }
92
-
93
- reporter.section('WG17 syntax');
94
- for (const item of fixture.cases) reporter.test(`#${item.id}`, () => assertOutcome(item));
95
- reporter.sectionTotal('WG17 syntax');
96
- }
97
-
98
- if (isMainModule(import.meta.url)) {
99
- const reporter = new TestReporter();
100
- try {
101
- runWg17Syntax(reporter);
102
- reporter.totalLine();
103
- } catch (_) {
104
- process.exitCode = 1;
105
- }
106
- }