eyeprolog 1.5.33 → 1.5.35
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/README.md +1 -1
- package/conformance-report.md +3 -2
- package/package.json +6 -4
- package/src/parser.js +11 -0
- package/src/program-analysis.js +35 -2
- package/src/solver.js +14 -12
- package/src/write.js +1 -2
- package/test/conformance/NEUMERKEL-LATEST.md +12 -8
- package/test/conformance/NEUMERKEL-LIVE.md +19 -9
- package/test/conformance/README.md +4 -2
- package/test/neumerkel.mjs +34 -16
- package/test/run-conformance-report.mjs +3 -2
- package/test/run-neumerkel-tests.mjs +83 -2
- package/test/run-neumerkel.mjs +29 -14
- package/test/run-regression.mjs +31 -0
- package/tools/upgrade-wg17.mjs +90 -17
package/README.md
CHANGED
|
@@ -95,5 +95,5 @@ cd eyeprolog
|
|
|
95
95
|
npm install
|
|
96
96
|
npm test
|
|
97
97
|
```
|
|
98
|
-
`npm test` is the release gate and fetches the latest seven Neumerkel conformity sources before the local gates; use `npm run test:offline` for a network-free pass, `npm run test:conformance` for all conformance layers, `npm run test:neumerkel` for live upstream only, and `npm run test:neumerkel:cached` only to reproduce the last fetch. Upstream counts are discovered dynamically
|
|
98
|
+
`npm test` is the release gate and fetches the latest seven Neumerkel conformity sources before the local gates; use `npm run test:offline` for a network-free pass, `npm run test:conformance` for all conformance layers, `npm run test:neumerkel` for live upstream only, and `npm run test:neumerkel:cached` only to reproduce the last fetch. Upstream counts are discovered dynamically. A stale tracked [latest Neumerkel report](test/conformance/NEUMERKEL-LATEST.md) produces a warning during normal tests. `npm run conformance:update:neumerkel` refreshes it from live upstream; `npm run conformance:sync:neumerkel` refreshes it from the exact successful snapshot already fetched by `npm test`; and `npm run conformance:check:neumerkel` verifies that snapshot without a second live fetch. npm's version lifecycle uses the sync path and stages the generated reports into the release commit. Exact bytes/hashes stay under Git-ignored `.cache/neumerkel/`. Benchmarks remain `npm run benchmark` and `npm run benchmark:lips`.
|
|
99
99
|
EyeProlog is released under the [MIT License](LICENSE.md).
|
package/conformance-report.md
CHANGED
|
@@ -7,8 +7,9 @@ when this report is generated; it is not inferred from fixture counts.
|
|
|
7
7
|
## Latest Neumerkel evidence
|
|
8
8
|
|
|
9
9
|
See the tracked [latest Neumerkel conformity report](test/conformance/NEUMERKEL-LATEST.md).
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
`npm test` fetches all seven TU Wien sources once and executes the discovered inventory.
|
|
11
|
+
The release workflow then synchronizes this tracked report from those exact successful
|
|
12
|
+
cached source bytes, avoiding a second live fetch and its race window.
|
|
12
13
|
|
|
13
14
|
## Executable conformance status
|
|
14
15
|
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "1.5.
|
|
6
|
+
"version": "1.5.35",
|
|
7
7
|
"description": "EyeProlog turns facts and rules into answers and proofs.",
|
|
8
8
|
"type": "module",
|
|
9
9
|
"main": "./index.js",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"test:conformance": "node test/run-conformance-all.mjs",
|
|
51
51
|
"test:conformance:offline": "node test/run-conformance-all.mjs --offline",
|
|
52
52
|
"test:neumerkel": "node test/run-neumerkel.mjs",
|
|
53
|
-
"test:neumerkel:cached": "node test/run-neumerkel.mjs --cached
|
|
53
|
+
"test:neumerkel:cached": "node test/run-neumerkel.mjs --cached",
|
|
54
54
|
"test:neumerkel:harness": "node test/run-neumerkel-tests.mjs",
|
|
55
55
|
"test:iso": "node test/run-iso-strict.mjs",
|
|
56
56
|
"test:wg17": "node test/run-wg17.mjs",
|
|
@@ -69,9 +69,11 @@
|
|
|
69
69
|
"generate": "node tools/generate-library-autoload-index.mjs && node tools/generate-predicate-reference.mjs && node tools/extract-book-examples.mjs",
|
|
70
70
|
"generate:autoload": "node tools/generate-library-autoload-index.mjs",
|
|
71
71
|
"generate:reference": "node tools/generate-predicate-reference.mjs",
|
|
72
|
-
"preversion": "npm test && npm run conformance:report",
|
|
72
|
+
"preversion": "npm test && npm run conformance:sync:neumerkel && npm run conformance:report && git add test/conformance/NEUMERKEL-LATEST.md conformance-report.md",
|
|
73
73
|
"postversion": "git push origin HEAD --follow-tags",
|
|
74
74
|
"conformance:update:wg17": "node tools/upgrade-wg17.mjs",
|
|
75
|
-
"conformance:update:neumerkel": "node test/run-neumerkel.mjs --update-report"
|
|
75
|
+
"conformance:update:neumerkel": "node test/run-neumerkel.mjs --update-report",
|
|
76
|
+
"conformance:check:neumerkel": "node test/run-neumerkel.mjs --cached --verify-report",
|
|
77
|
+
"conformance:sync:neumerkel": "node test/run-neumerkel.mjs --cached --update-report"
|
|
76
78
|
}
|
|
77
79
|
}
|
package/src/parser.js
CHANGED
|
@@ -811,6 +811,17 @@ class Parser {
|
|
|
811
811
|
let left = this.parsePrefixTerm(minPrecedence, allowBar, allowOperatorAtom);
|
|
812
812
|
const leftIsBareOperatorAtom = initialWasCurrentOperator &&
|
|
813
813
|
left.type === ATOM && left.name === initialOperatorName;
|
|
814
|
+
// Neumerkel syntax #379: the DCG rule operator `-->` is processor-defined
|
|
815
|
+
// and not an ISO predicate-indicator name. A bare `-->` (not parenthesized)
|
|
816
|
+
// followed by `/` would be an invalid strict-ISO predicate indicator.
|
|
817
|
+
// However, `(-->)/2` is valid: the parentheses make `-->` an ordinary atom
|
|
818
|
+
// argument, and ISO 7.10.3 does not restrict which atoms may appear as the
|
|
819
|
+
// name in a Name/Arity indicator — only the write-term rules govern spelling.
|
|
820
|
+
// Only reject the bare-operator form so (-->)/2 succeeds per #379.
|
|
821
|
+
if (this.strictIso && leftIsBareOperatorAtom && left.name === '-->' &&
|
|
822
|
+
this.operatorTokenName() === '/') {
|
|
823
|
+
throw new Error(`parse line ${this.token.line}: operator atom --> is not permitted in a strict ISO predicate indicator`);
|
|
824
|
+
}
|
|
814
825
|
let strictPostfixPrecedence = null;
|
|
815
826
|
while (true) {
|
|
816
827
|
const op = this.token.type === TOK.COMMA && allowComma
|
package/src/program-analysis.js
CHANGED
|
@@ -8,7 +8,11 @@ import {
|
|
|
8
8
|
|
|
9
9
|
export function componentHasNegativeEdge(start, deps, negativeEdges) {
|
|
10
10
|
const forward = reachableIndexes(start, deps);
|
|
11
|
-
|
|
11
|
+
// A node is in the same SCC as `start` iff it can also reach `start`.
|
|
12
|
+
// Rather than calling reachableIndexes() per-node (O(n^2)), compute the
|
|
13
|
+
// reverse-reachability set from `start` over the transposed graph once.
|
|
14
|
+
const backward = reachableIndexesTransposed(start, deps, forward);
|
|
15
|
+
const component = new Set([...forward].filter((index) => backward.has(index)));
|
|
12
16
|
return negativeEdges.some(([from, to]) => component.has(from) && component.has(to));
|
|
13
17
|
}
|
|
14
18
|
|
|
@@ -27,7 +31,8 @@ export function clauseIsDirectRecursive(clause, group) {
|
|
|
27
31
|
|
|
28
32
|
export function componentHasCut(start, deps, groups) {
|
|
29
33
|
const forward = reachableIndexes(start, deps);
|
|
30
|
-
const
|
|
34
|
+
const backward = reachableIndexesTransposed(start, deps, forward);
|
|
35
|
+
const component = [...forward].filter((index) => backward.has(index));
|
|
31
36
|
return component.some((index) => {
|
|
32
37
|
const group = groups[index];
|
|
33
38
|
const directRecursive = group.clauses.some((clause) => clauseIsDirectRecursive(clause, group));
|
|
@@ -50,6 +55,34 @@ export function reachableIndexes(start, deps) {
|
|
|
50
55
|
return seen;
|
|
51
56
|
}
|
|
52
57
|
|
|
58
|
+
// Returns the set of nodes in `candidates` that can reach `target` by
|
|
59
|
+
// traversing `deps` in reverse. This is equivalent to asking which nodes
|
|
60
|
+
// in the forward-reachable set from `target` also have `target` in their
|
|
61
|
+
// own forward-reachable set, but computed in a single BFS over the
|
|
62
|
+
// transposed graph rather than one BFS per candidate node.
|
|
63
|
+
function reachableIndexesTransposed(target, deps, candidates) {
|
|
64
|
+
// Build a transposed adjacency list restricted to the candidate set.
|
|
65
|
+
const reverse = new Map();
|
|
66
|
+
for (const from of candidates) {
|
|
67
|
+
if (!reverse.has(from)) reverse.set(from, []);
|
|
68
|
+
for (const to of deps[from]) {
|
|
69
|
+
if (!candidates.has(to)) continue;
|
|
70
|
+
let bucket = reverse.get(to);
|
|
71
|
+
if (bucket == null) { bucket = []; reverse.set(to, bucket); }
|
|
72
|
+
bucket.push(from);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const seen = new Set();
|
|
76
|
+
const stack = [target];
|
|
77
|
+
while (stack.length) {
|
|
78
|
+
const current = stack.pop();
|
|
79
|
+
if (seen.has(current)) continue;
|
|
80
|
+
seen.add(current);
|
|
81
|
+
for (const prev of reverse.get(current) ?? []) if (!seen.has(prev)) stack.push(prev);
|
|
82
|
+
}
|
|
83
|
+
return seen;
|
|
84
|
+
}
|
|
85
|
+
|
|
53
86
|
|
|
54
87
|
export function isFiniteDatalogArgument(term) {
|
|
55
88
|
return term?.type === VAR || term?.type === ATOM || term?.type === 'string' || term?.type === 'number';
|
package/src/solver.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Most semantic decisions still flow through unification; optimizations only select candidates earlier.
|
|
3
3
|
import {
|
|
4
4
|
ATOM, COMPOUND, NUMBER, STRING, VAR, Env, Term, compactListLength, compactVariableList, compound, cons, copyResolved, deref, emptyList,
|
|
5
|
-
flattenConjunction, freshTerm, isCons, isDecimalInteger, isEmptyList,
|
|
5
|
+
flattenConjunction, freshTerm, isCons, isDecimalInteger, isEmptyList, isScalar,
|
|
6
6
|
numberTerm, numberTextFromDouble, properListItems, termIsGround, termToString, unify, variable, variantTerms,
|
|
7
7
|
} from './term.js';
|
|
8
8
|
import { numberValueKey, sameNumberValue } from './number-value.js';
|
|
@@ -2692,11 +2692,14 @@ function matchScalarFact(goal, head, env) {
|
|
|
2692
2692
|
|
|
2693
2693
|
function derefScalarMatch(term, env, names, values) {
|
|
2694
2694
|
let current = term;
|
|
2695
|
-
|
|
2695
|
+
const seen = new Set();
|
|
2696
|
+
while (current?.type === 'var') {
|
|
2697
|
+
if (seen.has(current.name)) break;
|
|
2698
|
+
seen.add(current.name);
|
|
2696
2699
|
const localIndex = names.indexOf(current.name);
|
|
2697
|
-
if (localIndex >= 0) current = values[localIndex];
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
+
if (localIndex >= 0) { current = values[localIndex]; continue; }
|
|
2701
|
+
if (env.has(current.name)) { current = env.get(current.name); continue; }
|
|
2702
|
+
break;
|
|
2700
2703
|
}
|
|
2701
2704
|
return current;
|
|
2702
2705
|
}
|
|
@@ -3092,12 +3095,11 @@ function matchGroundBinaryClause(goal, clause) {
|
|
|
3092
3095
|
return { nextGoal: compound(bodyGoal.name, bodyArgs) };
|
|
3093
3096
|
}
|
|
3094
3097
|
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
}
|
|
3098
|
+
// isScalar is imported from term.js; use it as the canonical scalar test.
|
|
3099
|
+
const isScalarTerm = isScalar;
|
|
3098
3100
|
|
|
3099
3101
|
function sameScalarTerm(left, right) {
|
|
3100
|
-
return
|
|
3102
|
+
return isScalar(left) && isScalar(right) && left.type === right.type &&
|
|
3101
3103
|
(left.type === 'number' ? sameNumberValue(left.name, right.name) : left.name === right.name);
|
|
3102
3104
|
}
|
|
3103
3105
|
|
|
@@ -3131,9 +3133,9 @@ function sameResolvedGroundTerm(left, right, env) {
|
|
|
3131
3133
|
|
|
3132
3134
|
function groundChainKey(term) {
|
|
3133
3135
|
if (term?.type === COMPOUND) {
|
|
3134
|
-
|
|
3135
|
-
for (let i = 0; i < term.arity; i++)
|
|
3136
|
-
return
|
|
3136
|
+
const parts = [`${term.name}/${term.arity}`];
|
|
3137
|
+
for (let i = 0; i < term.arity; i++) parts.push(groundChainKey(term.args[i]));
|
|
3138
|
+
return parts.join('');
|
|
3137
3139
|
}
|
|
3138
3140
|
return `${term?.type ?? ''}:${term?.name ?? ''}`;
|
|
3139
3141
|
}
|
package/src/write.js
CHANGED
|
@@ -5,7 +5,6 @@ import {
|
|
|
5
5
|
} from './term.js';
|
|
6
6
|
|
|
7
7
|
const graphicAtomCharacters = new Set('!#$&*+-./<=>?@^~\\'.split(''));
|
|
8
|
-
const dottedGraphicAtomCharacters = graphicAtomCharacters;
|
|
9
8
|
const compactInfixOperators = new Set([':', '..']);
|
|
10
9
|
|
|
11
10
|
function quotedControlEscape(ch) {
|
|
@@ -60,7 +59,7 @@ function writeAtom(name) {
|
|
|
60
59
|
|
|
61
60
|
function isDottedGraphicAtom(name) {
|
|
62
61
|
return name.includes('.') && [...name].some((ch) => ch !== '.') && !name.startsWith('/*') &&
|
|
63
|
-
[...name].every((ch) =>
|
|
62
|
+
[...name].every((ch) => graphicAtomCharacters.has(ch));
|
|
64
63
|
}
|
|
65
64
|
|
|
66
65
|
function compactBoundaryNeedsSpace(left, right) {
|
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
# EyeProlog — latest Neumerkel conformity
|
|
2
2
|
|
|
3
|
-
Status: **PASS** — **
|
|
3
|
+
Status: **PASS** — **686/686** discovered upstream cases passed.
|
|
4
4
|
|
|
5
5
|
This tracked report records the latest upstream inventory successfully checked by EyeProlog.
|
|
6
|
-
`npm test` fetches the seven TU Wien sources again and
|
|
7
|
-
|
|
6
|
+
`npm test` fetches the seven TU Wien sources again and executes the discovered cases.
|
|
7
|
+
Release/report checks can additionally require these tracked counts to match the live suites.
|
|
8
|
+
Counts are output from upstream, not hard-coded test constants.
|
|
8
9
|
|
|
9
10
|
| Suite | Passed | Total |
|
|
10
11
|
|---|---:|---:|
|
|
11
|
-
| syntax |
|
|
12
|
-
| number_chars/2 |
|
|
12
|
+
| syntax | 379 | 379 |
|
|
13
|
+
| number_chars/2 | 86 | 86 |
|
|
13
14
|
| variable_names/1 | 75 | 75 |
|
|
14
15
|
| dif/2 | 26 | 26 |
|
|
15
16
|
| length/2 | 37 | 37 |
|
|
16
17
|
| phrase/2,3 | 58 | 58 |
|
|
17
18
|
| setup_call_cleanup/3 | 25 | 25 |
|
|
18
|
-
| **Total** | **
|
|
19
|
+
| **Total** | **686** | **686** |
|
|
19
20
|
|
|
20
21
|
## Upstream sources
|
|
21
22
|
|
|
@@ -29,6 +30,9 @@ still match the live suites. Counts are output from upstream, not hard-coded tes
|
|
|
29
30
|
|
|
30
31
|
Exact fetched bytes, SHA-256 hashes, fetch timestamps, and HTTP validators remain under
|
|
31
32
|
Git-ignored `.cache/neumerkel/` for local audit/reproduction and are intentionally not committed.
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
A normal test run warns when this tracked report is stale. Refresh directly from live
|
|
34
|
+
upstream with `npm run conformance:update:neumerkel`, or sync the exact successful
|
|
35
|
+
snapshot already fetched by `npm test` with `npm run conformance:sync:neumerkel`.
|
|
36
|
+
`npm run conformance:check:neumerkel` verifies the tracked report against that last
|
|
37
|
+
successful live snapshot without fetching upstream a second time.
|
|
34
38
|
|
|
@@ -15,7 +15,11 @@ counts.
|
|
|
15
15
|
7. `cleanup` — `setup_call_cleanup/3` examples.
|
|
16
16
|
|
|
17
17
|
The runner discovers the inventory at run time. A new upstream row is therefore
|
|
18
|
-
executed automatically and a removed row disappears automatically.
|
|
18
|
+
executed automatically and a removed row disappears automatically. The syntax
|
|
19
|
+
extractor keys the expected result from TU Wien's labelled `Codex` column rather
|
|
20
|
+
than assuming a fixed cell position, and cross-checks the discovered inventory
|
|
21
|
+
against the total declared by the live page so hand-edited HTML cannot silently
|
|
22
|
+
reduce coverage.
|
|
19
23
|
|
|
20
24
|
## Tracked GitHub evidence
|
|
21
25
|
|
|
@@ -23,18 +27,22 @@ The latest successful discovered inventory is committed as
|
|
|
23
27
|
[`NEUMERKEL-LATEST.md`](NEUMERKEL-LATEST.md). This is the stable report to link
|
|
24
28
|
from GitHub, releases, or other documentation.
|
|
25
29
|
|
|
26
|
-
A normal live run
|
|
27
|
-
|
|
28
|
-
|
|
30
|
+
A normal live run always executes the current upstream inventory. If the tracked
|
|
31
|
+
Markdown no longer matches, the test still reflects engine conformance and prints a
|
|
32
|
+
warning with the refresh command:
|
|
29
33
|
|
|
30
34
|
```sh
|
|
31
35
|
npm run conformance:update:neumerkel
|
|
32
36
|
```
|
|
33
37
|
|
|
34
38
|
Commit the resulting `test/conformance/NEUMERKEL-LATEST.md` after reviewing the
|
|
35
|
-
change.
|
|
36
|
-
|
|
37
|
-
|
|
39
|
+
change. After a successful `npm test`, `npm run conformance:sync:neumerkel` writes
|
|
40
|
+
the tracked report from the exact cached source bytes that just passed, avoiding a
|
|
41
|
+
second network fetch. `npm run conformance:check:neumerkel` verifies the tracked
|
|
42
|
+
report against that same last successful snapshot. The npm version lifecycle
|
|
43
|
+
uses this race-free sync path and stages the generated reports into the release
|
|
44
|
+
commit. The tracked report intentionally omits fetch timestamps and HTTP validators,
|
|
45
|
+
so repeated runs against unchanged upstream suites do not dirty the checkout.
|
|
38
46
|
|
|
39
47
|
## Local audit cache
|
|
40
48
|
|
|
@@ -55,8 +63,10 @@ fetch, use:
|
|
|
55
63
|
npm run test:neumerkel:cached
|
|
56
64
|
```
|
|
57
65
|
|
|
58
|
-
The cached command never claims to check the latest upstream suites
|
|
59
|
-
|
|
66
|
+
The cached command never claims to check the latest upstream suites by itself.
|
|
67
|
+
The release flow first performs the canonical live `npm test`, then uses those exact
|
|
68
|
+
just-fetched bytes only to synchronize and verify the tracked evidence without
|
|
69
|
+
contacting upstream twice.
|
|
60
70
|
|
|
61
71
|
The vendored WG17 syntax matrix remains useful as a deterministic reviewed
|
|
62
72
|
regression snapshot, but it is secondary to this live gate: passing the snapshot
|
|
@@ -89,18 +89,20 @@ npm run test:conformance # live Neumerkel + local ISO/conformance layer
|
|
|
89
89
|
npm run test:conformance:offline # same local layers, no network
|
|
90
90
|
npm run test:neumerkel # the seven live upstream suites only
|
|
91
91
|
npm run test:neumerkel:cached # exact last fetched bytes; reproduction only
|
|
92
|
+
npm run conformance:check:neumerkel # verify tracked report against last successful live snapshot
|
|
92
93
|
npm run test:iso # Part 1 + Corrigenda strict-core processor gate
|
|
93
94
|
npm run test:wg17 # vendored reviewed WG17 syntax regression
|
|
94
95
|
```
|
|
95
96
|
|
|
96
97
|
`test:neumerkel` always fetches the current TU Wien sources. It does not skip a
|
|
97
|
-
fetch because a cache exists. The runner discovers the number of active tests from those sources and fails on any newly introduced case EyeProlog does not pass.
|
|
98
|
+
fetch because a cache exists. The runner discovers the number of active tests from those sources and fails on any newly introduced case EyeProlog does not pass. If the stable, tracked [NEUMERKEL-LATEST.md](NEUMERKEL-LATEST.md) is stale, normal tests warn rather than turning a passing engine run into a failure. `npm run conformance:update:neumerkel` performs a fresh live run and refreshes the report; after `npm test`, `npm run conformance:sync:neumerkel` refreshes it from the exact successful cached snapshot; and `npm run conformance:check:neumerkel` verifies that snapshot without a second network fetch. Exact bytes, SHA-256 hashes, timestamps, and HTTP validators stay under Git-ignored `.cache/neumerkel/` for audit/reproduction only. See [NEUMERKEL-LIVE.md](NEUMERKEL-LIVE.md).
|
|
98
99
|
|
|
99
100
|
The vendored WG17 syntax snapshot is intentionally secondary. Update all upstream conformance evidence with `npm run conformance:update`, or use the focused commands:
|
|
100
101
|
|
|
101
102
|
```sh
|
|
102
103
|
npm run conformance:update:wg17
|
|
103
104
|
npm run conformance:update:neumerkel
|
|
105
|
+
npm run conformance:sync:neumerkel
|
|
104
106
|
npm run test:wg17
|
|
105
107
|
```
|
|
106
108
|
|
|
@@ -134,7 +136,7 @@ Selected cases are adapted from the ISO and standard-core suites of Logtalk,
|
|
|
134
136
|
Scryer Prolog, Trealla Prolog, and SWI-Prolog. Their upstream identifiers and licenses
|
|
135
137
|
are recorded in [THIRD_PARTY.md](THIRD_PARTY.md).
|
|
136
138
|
|
|
137
|
-
The corpus has 386 cases in `iso/` and 802 file-based conformance cases in total. Of those, 11 cases in `stc/` are explicitly labelled working-draft review evidence rather than normative ISO claims. The separate vendored strict-reader WG17 matrix is a deterministic regression snapshot; the live Neumerkel gate discovers the current upstream inventory at run time
|
|
139
|
+
The corpus has 386 cases in `iso/` and 802 file-based conformance cases in total. Of those, 11 cases in `stc/` are explicitly labelled working-draft review evidence rather than normative ISO claims. The separate vendored strict-reader WG17 matrix is a deterministic regression snapshot; the live Neumerkel gate discovers the current upstream inventory at run time; release/report checks separately verify the tracked `NEUMERKEL-LATEST.md`. The generated `conformance-report.md` records local corpus totals and links to that live evidence. Together with regression, documentation-sync, API, example, and book-example checks, `npm test` is the release gate.
|
|
138
140
|
|
|
139
141
|
## Updating expected output
|
|
140
142
|
|
package/test/neumerkel.mjs
CHANGED
|
@@ -337,22 +337,36 @@ export async function executeNeumerkel({ reporter, mode = 'live', cacheDir = def
|
|
|
337
337
|
reporter.section(`Neumerkel conformity (${mode === 'live' && sourceDir == null ? 'live upstream' : sourceDir != null ? 'source fixtures' : 'cached'})`);
|
|
338
338
|
|
|
339
339
|
const syntaxCases = materializeSyntaxCases(sources.get('syntax').text);
|
|
340
|
+
const syntaxFailures = [];
|
|
341
|
+
let syntaxPassed = 0;
|
|
340
342
|
for (const item of syntaxCases) {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
343
|
+
try {
|
|
344
|
+
reporter.test(`syntax ${wg17TestDescription(item)}`, () => {
|
|
345
|
+
const actual = executeWg17Item(item);
|
|
346
|
+
if (!matchesUpstreamExpectation(item.expected, actual, item)) {
|
|
347
|
+
throw new Error(`syntax #${item.id} expected ${item.expected}; actual ${JSON.stringify(actual)}`);
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
syntaxPassed++;
|
|
351
|
+
} catch (error) {
|
|
352
|
+
// Upstream changes often arrive in small clusters. Keep running the
|
|
353
|
+
// syntax inventory so one live test run exposes every new mismatch
|
|
354
|
+
// instead of forcing a fix/rerun cycle for each row.
|
|
355
|
+
syntaxFailures.push(error);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
summary.syntax = { passed: syntaxPassed, total: syntaxCases.length };
|
|
359
|
+
if (syntaxFailures.length > 0) {
|
|
360
|
+
throw new AggregateError(
|
|
361
|
+
syntaxFailures,
|
|
362
|
+
`${syntaxFailures.length} live Neumerkel syntax case${syntaxFailures.length === 1 ? '' : 's'} failed`,
|
|
363
|
+
);
|
|
347
364
|
}
|
|
348
|
-
summary.syntax = { passed: syntaxCases.length, total: syntaxCases.length };
|
|
349
365
|
|
|
350
366
|
for (const key of ['number_chars', 'variable_names', 'length', 'phrase']) {
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
});
|
|
355
|
-
summary[key] = { passed: result.total, total: result.total };
|
|
367
|
+
const result = reporter.batch(`${key.replace('_', ' ')} live corpus`, () =>
|
|
368
|
+
ensureQuadSuccess(key, sources.get(key)));
|
|
369
|
+
summary[key] = { passed: result.passed, total: result.total };
|
|
356
370
|
}
|
|
357
371
|
|
|
358
372
|
const difCases = parseDifCases(sources.get('dif').text);
|
|
@@ -440,8 +454,9 @@ export function formatNeumerkelMarkdown({ summary }) {
|
|
|
440
454
|
`Status: **${passed === total ? 'PASS' : 'FAIL'}** — **${passed}/${total}** discovered upstream cases passed.`,
|
|
441
455
|
'',
|
|
442
456
|
'This tracked report records the latest upstream inventory successfully checked by EyeProlog.',
|
|
443
|
-
'`npm test` fetches the seven TU Wien sources again and
|
|
444
|
-
'
|
|
457
|
+
'`npm test` fetches the seven TU Wien sources again and executes the discovered cases.',
|
|
458
|
+
'Release/report checks can additionally require these tracked counts to match the live suites.',
|
|
459
|
+
'Counts are output from upstream, not hard-coded test constants.',
|
|
445
460
|
'',
|
|
446
461
|
'| Suite | Passed | Total |',
|
|
447
462
|
'|---|---:|---:|',
|
|
@@ -460,8 +475,11 @@ export function formatNeumerkelMarkdown({ summary }) {
|
|
|
460
475
|
'',
|
|
461
476
|
'Exact fetched bytes, SHA-256 hashes, fetch timestamps, and HTTP validators remain under',
|
|
462
477
|
'Git-ignored `.cache/neumerkel/` for local audit/reproduction and are intentionally not committed.',
|
|
463
|
-
'
|
|
464
|
-
'
|
|
478
|
+
'A normal test run warns when this tracked report is stale. Refresh directly from live',
|
|
479
|
+
'upstream with `npm run conformance:update:neumerkel`, or sync the exact successful',
|
|
480
|
+
'snapshot already fetched by `npm test` with `npm run conformance:sync:neumerkel`.',
|
|
481
|
+
'`npm run conformance:check:neumerkel` verifies the tracked report against that last',
|
|
482
|
+
'successful live snapshot without fetching upstream a second time.',
|
|
465
483
|
'',
|
|
466
484
|
);
|
|
467
485
|
return `${lines.join('\n')}\n`;
|
|
@@ -84,8 +84,9 @@ export function formatConformanceReport(report = buildConformanceReport()) {
|
|
|
84
84
|
'## Latest Neumerkel evidence',
|
|
85
85
|
'',
|
|
86
86
|
'See the tracked [latest Neumerkel conformity report](test/conformance/NEUMERKEL-LATEST.md).',
|
|
87
|
-
'
|
|
88
|
-
'
|
|
87
|
+
'`npm test` fetches all seven TU Wien sources once and executes the discovered inventory.',
|
|
88
|
+
'The release workflow then synchronizes this tracked report from those exact successful',
|
|
89
|
+
'cached source bytes, avoiding a second live fetch and its race window.',
|
|
89
90
|
'',
|
|
90
91
|
);
|
|
91
92
|
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { NEUMERKEL_SOURCES, formatNeumerkelMarkdown, materializeSyntaxCases, parseCleanupCases, parseDifCases } from './neumerkel.mjs';
|
|
3
|
+
import { executeWg17Item, matchesUpstreamExpectation } from './run-wg17.mjs';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
3
7
|
import { TestReporter, isMainModule, runStandalone } from './test-style.mjs';
|
|
4
8
|
|
|
9
|
+
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
10
|
+
|
|
5
11
|
function syntaxHtml(count = 101) {
|
|
6
12
|
const rows = [];
|
|
7
13
|
for (let id = 1; id <= count; id++) {
|
|
@@ -12,6 +18,13 @@ function syntaxHtml(count = 101) {
|
|
|
12
18
|
return `<table>${rows.join('')}</table>`;
|
|
13
19
|
}
|
|
14
20
|
|
|
21
|
+
function syntaxHtmlWithBareAnchors() {
|
|
22
|
+
const rows = [];
|
|
23
|
+
for (let id = 1; id <= 366; id++) rows.push(`<tr><td><a name=${id}>${id}</a><td>true.<td>succeeds`);
|
|
24
|
+
for (let id = 367; id <= 376; id++) rows.push(`<td><a name=${id}>${id}</a><td>true.<td>succeeds`);
|
|
25
|
+
return `<table><tr><td>number of conforming queries<td>376/376${rows.join('')}</table>`;
|
|
26
|
+
}
|
|
27
|
+
|
|
15
28
|
export function runNeumerkelHarnessTests(reporter = new TestReporter()) {
|
|
16
29
|
reporter.section('Neumerkel harness');
|
|
17
30
|
|
|
@@ -27,6 +40,57 @@ export function runNeumerkelHarnessTests(reporter = new TestReporter()) {
|
|
|
27
40
|
if (!cases[1].input.includes('op(9,fy,x)')) throw new Error('/**/ setup was not reconstructed');
|
|
28
41
|
});
|
|
29
42
|
|
|
43
|
+
reporter.test('syntax discovery includes bare anchored rows appended without tr', () => {
|
|
44
|
+
const cases = materializeSyntaxCases(syntaxHtmlWithBareAnchors());
|
|
45
|
+
if (cases.length !== 376) throw new Error(`expected 376 cases, got ${cases.length}`);
|
|
46
|
+
if (cases.at(-1)?.id !== 376) throw new Error('last bare anchored syntax row was not discovered');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
reporter.test('syntax discovery uses the Codex-labelled expected cell on malformed appended rows', () => {
|
|
50
|
+
const rows = [];
|
|
51
|
+
for (let id = 1; id <= 100; id++) rows.push(`<tr><td>${id}<td>true.<td class=codx>succeeds`);
|
|
52
|
+
// A hand-edited row can acquire an extra cell before the semantic Codex
|
|
53
|
+
// column. Positional extraction would incorrectly read "syntax err.".
|
|
54
|
+
rows.push('<td><a name=101>101</a><td>writeq(a).<td>syntax err.<td class=codx>a<td>OK');
|
|
55
|
+
const cases = materializeSyntaxCases(`<table>${rows.join('')}</table>`);
|
|
56
|
+
const item = cases.find(({ id }) => id === 101);
|
|
57
|
+
if (item?.expected !== 'a') {
|
|
58
|
+
throw new Error(`expected Codex-labelled result, got ${JSON.stringify(item)}`);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
reporter.test('latest strict syntax allows Neumerkel #379 parenthesized --> atom', () => {
|
|
63
|
+
// Upstream Codex expectation for #379: writeq((-->)/2). -> (-->)/2
|
|
64
|
+
// (-->)/2 is valid: parentheses make --> an ordinary atom; only bare
|
|
65
|
+
// --> followed by / in a predicate indicator is processor-defined syntax.
|
|
66
|
+
const item = {
|
|
67
|
+
id: 379,
|
|
68
|
+
query: 'writeq((-->)/2).',
|
|
69
|
+
input: 'writeq((-->)/2).',
|
|
70
|
+
readCount: 1,
|
|
71
|
+
expected: '(-->)/2',
|
|
72
|
+
};
|
|
73
|
+
const actual = executeWg17Item(item);
|
|
74
|
+
if (!matchesUpstreamExpectation(item.expected, actual, item)) {
|
|
75
|
+
throw new Error(`Neumerkel #379 did not match: ${JSON.stringify(actual)}`);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
reporter.test('syntax discovery fails loudly when a labelled live row loses its Codex cell', () => {
|
|
80
|
+
const rows = [];
|
|
81
|
+
for (let id = 1; id <= 100; id++) rows.push(`<tr><td>${id}<td>true.<td class=codx>succeeds`);
|
|
82
|
+
rows.push('<td><a name=101>101</a><td>true.<td>succeeds');
|
|
83
|
+
let caught = null;
|
|
84
|
+
try {
|
|
85
|
+
materializeSyntaxCases(`<table>${rows.join('')}</table>`);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
caught = error;
|
|
88
|
+
}
|
|
89
|
+
if (!caught || !String(caught.message).includes('no labelled Codex cell')) {
|
|
90
|
+
throw new Error(`missing Codex column was not rejected: ${caught?.message ?? 'no error'}`);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
30
94
|
reporter.test('dif table discovery uses upstream row ids and answer descriptions', () => {
|
|
31
95
|
const cases = parseDifCases('<table><tr><td>1<td>?- dif(1,2).<td>true<tr><td>2<td>?- dif(X,X).<td>false</table>');
|
|
32
96
|
if (cases.length !== 2 || cases[0].expected !== 'succeeds' || cases[1].expected !== 'fails') {
|
|
@@ -36,7 +100,7 @@ export function runNeumerkelHarnessTests(reporter = new TestReporter()) {
|
|
|
36
100
|
|
|
37
101
|
reporter.test('tracked Markdown report records dynamic counts without volatile metadata', () => {
|
|
38
102
|
const summary = {
|
|
39
|
-
syntax: { passed:
|
|
103
|
+
syntax: { passed: 376, total: 376 },
|
|
40
104
|
number_chars: { passed: 78, total: 78 },
|
|
41
105
|
variable_names: { passed: 75, total: 75 },
|
|
42
106
|
dif: { passed: 26, total: 26 },
|
|
@@ -48,7 +112,7 @@ export function runNeumerkelHarnessTests(reporter = new TestReporter()) {
|
|
|
48
112
|
summary,
|
|
49
113
|
manifest: { fetchedAt: '2026-09-03T12:00:00.000Z', sources: [{ etag: '"volatile-tag"' }] },
|
|
50
114
|
});
|
|
51
|
-
if (!text.includes('**
|
|
115
|
+
if (!text.includes('**675/675**')) throw new Error('Markdown total is not derived from suite counts');
|
|
52
116
|
if (!text.includes('| setup_call_cleanup/3 | 25 | 25 |')) throw new Error('cleanup row missing');
|
|
53
117
|
if (!text.includes('https://www.complang.tuwien.ac.at/ulrich/iso-prolog/conformity_testing')) throw new Error('upstream source link missing');
|
|
54
118
|
if (text.includes('2026-09-03T12:00:00.000Z') || text.includes('volatile-tag')) {
|
|
@@ -56,6 +120,23 @@ export function runNeumerkelHarnessTests(reporter = new TestReporter()) {
|
|
|
56
120
|
}
|
|
57
121
|
});
|
|
58
122
|
|
|
123
|
+
reporter.test('release workflow reuses the successful live snapshot instead of refetching', () => {
|
|
124
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'));
|
|
125
|
+
const scripts = pkg.scripts ?? {};
|
|
126
|
+
if (scripts['conformance:sync:neumerkel'] !== 'node test/run-neumerkel.mjs --cached --update-report') {
|
|
127
|
+
throw new Error('Neumerkel sync must update from the cached successful live snapshot');
|
|
128
|
+
}
|
|
129
|
+
if (scripts['conformance:check:neumerkel'] !== 'node test/run-neumerkel.mjs --cached --verify-report') {
|
|
130
|
+
throw new Error('Neumerkel report check must not refetch live upstream');
|
|
131
|
+
}
|
|
132
|
+
if (!String(scripts.preversion ?? '').includes('conformance:sync:neumerkel')) {
|
|
133
|
+
throw new Error('preversion must synchronize the tracked report from the successful npm test snapshot');
|
|
134
|
+
}
|
|
135
|
+
if (String(scripts.preversion ?? '').includes('conformance:check:neumerkel')) {
|
|
136
|
+
throw new Error('preversion must not perform a second report check/fetch cycle after synchronization');
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
59
140
|
reporter.test('cleanup discovery follows the reference one-line example protocol', () => {
|
|
60
141
|
const html = `<pre>
|
|
61
142
|
setup_call_cleanup(fail,_,_).
|
package/test/run-neumerkel.mjs
CHANGED
|
@@ -10,12 +10,13 @@ function parseArgs(argv) {
|
|
|
10
10
|
mode: 'live',
|
|
11
11
|
sourceDir: process.env.EYEPROLOG_NEUMERKEL_SOURCE_DIR ?? null,
|
|
12
12
|
updateReport: false,
|
|
13
|
-
verifyReport:
|
|
13
|
+
verifyReport: false,
|
|
14
14
|
};
|
|
15
15
|
for (let index = 0; index < argv.length; index++) {
|
|
16
16
|
const arg = argv[index];
|
|
17
17
|
if (arg === '--cached') options.mode = 'cached';
|
|
18
18
|
else if (arg === '--update-report') options.updateReport = true;
|
|
19
|
+
else if (arg === '--verify-report') options.verifyReport = true;
|
|
19
20
|
else if (arg === '--no-verify-report') options.verifyReport = false;
|
|
20
21
|
else if (arg === '--source-dir') {
|
|
21
22
|
if (argv[index + 1] == null) throw new Error('--source-dir requires a directory');
|
|
@@ -28,16 +29,16 @@ function parseArgs(argv) {
|
|
|
28
29
|
|
|
29
30
|
function printHelp() {
|
|
30
31
|
process.stdout.write(
|
|
31
|
-
'Usage: npm run test:neumerkel -- [--cached] [--source-dir DIR] [--
|
|
32
|
-
'Default: fetch all seven current Neumerkel conformity sources live
|
|
33
|
-
'discovered
|
|
34
|
-
'
|
|
35
|
-
'--
|
|
32
|
+
'Usage: npm run test:neumerkel -- [--cached] [--source-dir DIR] [--verify-report] [--update-report]\n\n' +
|
|
33
|
+
'Default: fetch all seven current Neumerkel conformity sources live and run\n' +
|
|
34
|
+
'every discovered case. A stale tracked report is reported as a warning, not\n' +
|
|
35
|
+
'an engine-test failure. --verify-report makes report freshness mandatory;\n' +
|
|
36
|
+
'--update-report refreshes the tracked Markdown. --cached is reproduction only.\n',
|
|
36
37
|
);
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
export async function runNeumerkel(reporter, options = {}) {
|
|
40
|
-
const effective = { verifyReport:
|
|
41
|
+
const effective = { verifyReport: false, updateReport: false, ...options };
|
|
41
42
|
if (effective.sourceDir == null && process.env.EYEPROLOG_NEUMERKEL_SOURCE_DIR) {
|
|
42
43
|
effective.sourceDir = path.resolve(process.env.EYEPROLOG_NEUMERKEL_SOURCE_DIR);
|
|
43
44
|
}
|
|
@@ -48,17 +49,17 @@ export async function runNeumerkel(reporter, options = {}) {
|
|
|
48
49
|
fs.mkdirSync(path.dirname(result.reportPath), { recursive: true });
|
|
49
50
|
fs.writeFileSync(result.reportPath, result.reportText);
|
|
50
51
|
reporter.stdout.write(`Updated Neumerkel report: ${relativeReportPath}\n`);
|
|
51
|
-
} else
|
|
52
|
+
} else {
|
|
52
53
|
const committed = fs.existsSync(result.reportPath) ? fs.readFileSync(result.reportPath, 'utf8') : null;
|
|
53
54
|
if (committed !== result.reportText) {
|
|
54
|
-
|
|
55
|
+
const message =
|
|
55
56
|
`tracked Neumerkel report is stale: ${relativeReportPath}\n` +
|
|
56
|
-
'Run npm run conformance:update:neumerkel and commit the updated report.'
|
|
57
|
-
);
|
|
57
|
+
'Run npm run conformance:update:neumerkel and commit the updated report.';
|
|
58
|
+
if (effective.verifyReport) throw new Error(message);
|
|
59
|
+
reporter.stdout.write(`WARN ${message}\n`);
|
|
60
|
+
} else {
|
|
61
|
+
reporter.stdout.write(`Neumerkel report: ${relativeReportPath}\n`);
|
|
58
62
|
}
|
|
59
|
-
reporter.stdout.write(`Neumerkel report: ${relativeReportPath}\n`);
|
|
60
|
-
} else {
|
|
61
|
-
reporter.stdout.write(`Neumerkel report verification skipped (${relativeReportPath})\n`);
|
|
62
63
|
}
|
|
63
64
|
return result;
|
|
64
65
|
}
|
|
@@ -85,6 +86,20 @@ function quietNeumerkelReporter(reporter) {
|
|
|
85
86
|
throw error;
|
|
86
87
|
}
|
|
87
88
|
},
|
|
89
|
+
batch(name, run) {
|
|
90
|
+
const startedAt = nowMs();
|
|
91
|
+
try {
|
|
92
|
+
const result = run();
|
|
93
|
+
reporter.total += result.total;
|
|
94
|
+
reporter.ok += result.passed;
|
|
95
|
+
return result;
|
|
96
|
+
} catch (error) {
|
|
97
|
+
const ms = nowMs() - startedAt;
|
|
98
|
+
reporter.stderr.write(`FAIL ${name} (${ms} ms)\n`);
|
|
99
|
+
reporter.stderr.write(`${error?.stack ?? String(error)}\n`);
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
},
|
|
88
103
|
};
|
|
89
104
|
}
|
|
90
105
|
|
package/test/run-regression.mjs
CHANGED
|
@@ -1955,6 +1955,37 @@ c4 ?- call((!;1)).
|
|
|
1955
1955
|
assertIncludes(caught.message, 'operator atom', 'syntax rejection');
|
|
1956
1956
|
},
|
|
1957
1957
|
},
|
|
1958
|
+
{
|
|
1959
|
+
name: 'ISO predicate indicators require parentheses around bare operator atoms',
|
|
1960
|
+
run: () => {
|
|
1961
|
+
// Bare --> /2 (without parens) is still rejected in all modes because
|
|
1962
|
+
// --> is an infix/prefix operator atom that cannot appear as a bare operand.
|
|
1963
|
+
let caught = null;
|
|
1964
|
+
try {
|
|
1965
|
+
parseGoalText('writeq(--> /2)');
|
|
1966
|
+
} catch (error) {
|
|
1967
|
+
caught = error;
|
|
1968
|
+
}
|
|
1969
|
+
if (!caught) throw new Error('bare operator predicate indicator unexpectedly parsed');
|
|
1970
|
+
assertIncludes(caught.message, 'operator atom', 'bare indicator syntax rejection');
|
|
1971
|
+
// (-->)/2 with parentheses is legal in both normal and strict ISO modes.
|
|
1972
|
+
// Neumerkel #379: upstream Codex expectation is (-->)/2 -> (-->)/2 (success).
|
|
1973
|
+
// Parentheses make --> an ordinary atom argument; ISO 7.10.3 does not
|
|
1974
|
+
// restrict which atoms may appear as the name in a Name/Arity indicator.
|
|
1975
|
+
parseGoalText('writeq((-->)/2)');
|
|
1976
|
+
assertEqual(
|
|
1977
|
+
run('', { goal: 'writeq((-->)/2)' }).stdout,
|
|
1978
|
+
'(-->)/2writeq((-->) / 2).\n',
|
|
1979
|
+
'parenthesized operator indicator stays legal outside strict ISO',
|
|
1980
|
+
);
|
|
1981
|
+
parseGoalText('writeq((-->)/2)', { isoStrict: true });
|
|
1982
|
+
assertEqual(
|
|
1983
|
+
run('', { isoStrict: true, goal: 'writeq((-->)/2)' }).stdout,
|
|
1984
|
+
'(-->)/2writeq((-->) / 2).\n',
|
|
1985
|
+
'Neumerkel #379: parenthesized --> is legal in strict ISO',
|
|
1986
|
+
);
|
|
1987
|
+
},
|
|
1988
|
+
},
|
|
1958
1989
|
{
|
|
1959
1990
|
name: 'CLP(Z) operator declarations avoid unnecessary quoted atoms',
|
|
1960
1991
|
run: () => {
|
package/tools/upgrade-wg17.mjs
CHANGED
|
@@ -58,6 +58,28 @@ function deletedRow(rowHtml, idCellHtml) {
|
|
|
58
58
|
/class\s*=\s*["'][^"']*\b(?:deleted|obsolete|removed)\b/i.test(rowHtml);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
function cellsFromChunk(body) {
|
|
62
|
+
const cellStarts = [...body.matchAll(/<t[dh]\b[^>]*>/gi)];
|
|
63
|
+
const cells = [];
|
|
64
|
+
for (let cellIndex = 0; cellIndex < cellStarts.length; cellIndex++) {
|
|
65
|
+
const cellStart = cellStarts[cellIndex];
|
|
66
|
+
const cellBodyStart = cellStart.index + cellStart[0].length;
|
|
67
|
+
const cellEnd = cellStarts[cellIndex + 1]?.index ?? body.length;
|
|
68
|
+
cells.push({
|
|
69
|
+
openTag: cellStart[0],
|
|
70
|
+
body: body.slice(cellBodyStart, cellEnd),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return cells;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function cellHasClass(cell, className) {
|
|
77
|
+
const escaped = className.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
78
|
+
const quoted = new RegExp(`\\bclass\\s*=\\s*[\"']([^\"']*)[\"']`, 'i').exec(cell.openTag)?.[1] ?? '';
|
|
79
|
+
if (quoted.split(/\s+/).some((part) => part.toLowerCase() === className.toLowerCase())) return true;
|
|
80
|
+
return new RegExp(`\\bclass\\s*=\\s*${escaped}(?:\\s|>|$)`, 'i').test(cell.openTag);
|
|
81
|
+
}
|
|
82
|
+
|
|
61
83
|
function htmlTableRows(html) {
|
|
62
84
|
// TU Wien intentionally serves very small, old-style HTML. In valid HTML,
|
|
63
85
|
// </td> and </tr> are optional, and the conformity page currently relies
|
|
@@ -73,38 +95,82 @@ function htmlTableRows(html) {
|
|
|
73
95
|
const rowEnd = explicitEnd < 0 ? nextRow : bodyStart + explicitEnd;
|
|
74
96
|
const rowHtml = html.slice(rowStart.index, rowEnd);
|
|
75
97
|
const body = html.slice(bodyStart, rowEnd);
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
98
|
+
rows.push({ start: rowStart.index, rowHtml, cells: cellsFromChunk(body) });
|
|
99
|
+
}
|
|
100
|
+
return rows;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function htmlAnchoredSyntaxRows(html) {
|
|
104
|
+
// The conformity page is hand-edited old-style HTML. New rows are sometimes
|
|
105
|
+
// appended as bare `<td><a name=N>` anchors without a surrounding `<tr>`.
|
|
106
|
+
// Treat every numbered first-cell anchor as a row boundary so new upstream
|
|
107
|
+
// cases cannot be silently glued to the previous row.
|
|
108
|
+
const anchors = [...html.matchAll(/<td\b[^>]*>\s*<a\b[^>]*\bname\s*=\s*["']?\d+["']?[^>]*>/gi)];
|
|
109
|
+
const rows = [];
|
|
110
|
+
for (let index = 0; index < anchors.length; index++) {
|
|
111
|
+
const start = anchors[index].index;
|
|
112
|
+
const end = anchors[index + 1]?.index ?? html.length;
|
|
113
|
+
const rowHtml = html.slice(start, end);
|
|
114
|
+
rows.push({ start, rowHtml, cells: cellsFromChunk(rowHtml) });
|
|
85
115
|
}
|
|
86
116
|
return rows;
|
|
87
117
|
}
|
|
88
118
|
|
|
119
|
+
function declaredSyntaxTotal(html) {
|
|
120
|
+
const marker = html.search(/number of conforming queries/i);
|
|
121
|
+
if (marker < 0) return null;
|
|
122
|
+
const text = decodeHtmlEntities(html.slice(marker, marker + 2000).replace(/<[^>]+>/g, ' '));
|
|
123
|
+
const match = text.match(/(\d+)\s*\/\s*(\d+)/);
|
|
124
|
+
return match == null ? null : Number(match[2]);
|
|
125
|
+
}
|
|
126
|
+
|
|
89
127
|
export function parseWg17SyntaxTable(html) {
|
|
90
128
|
const cases = [];
|
|
91
129
|
const seen = new Set();
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
130
|
+
// Real TU Wien conformity rows label the standards/Codex expectation. If
|
|
131
|
+
// the page uses that label anywhere, require it for every numbered test
|
|
132
|
+
// row instead of silently falling back to a physical column position.
|
|
133
|
+
const labelledCodexPage = /<t[dh]\b[^>]*\bclass\s*=\s*(?:["'][^"']*\bcodx\b[^"']*["']|codx(?:\s|>|$))/i.test(html);
|
|
134
|
+
// Merge ordinary `<tr>` rows with numbered first-cell anchors in document
|
|
135
|
+
// order. The anchor view supplies hand-appended rows that omit `<tr>`; the
|
|
136
|
+
// normal view still handles fixtures/pages without named anchors.
|
|
137
|
+
const rows = [...htmlTableRows(html), ...htmlAnchoredSyntaxRows(html)]
|
|
138
|
+
.sort((a, b) => a.start - b.start);
|
|
139
|
+
const byId = new Map();
|
|
140
|
+
for (const { rowHtml, cells } of rows) {
|
|
141
|
+
if (cells.length < 3 || deletedRow(rowHtml, cells[0].body)) continue;
|
|
142
|
+
|
|
143
|
+
const idText = htmlCellText(cells[0].body).replace(/^#\s*/, '');
|
|
96
144
|
const idMatch = idText.match(/^(\d+)$/);
|
|
97
145
|
if (idMatch == null) continue;
|
|
98
146
|
const id = Number(idMatch[1]);
|
|
99
|
-
if (seen.has(id)) throw new Error(`duplicate active WG17 syntax id #${id} in upstream table`);
|
|
100
147
|
|
|
101
148
|
// TU Wien uses non-breaking spaces for table presentation/indentation.
|
|
102
149
|
// They are not part of the Prolog source being specified, so normalize
|
|
103
150
|
// them to ordinary spaces before snapshotting/comparing rows.
|
|
104
|
-
const query = htmlCellText(cells[1]).replace(/\u00a0/g, ' ');
|
|
105
|
-
|
|
151
|
+
const query = htmlCellText(cells[1].body).replace(/\u00a0/g, ' ');
|
|
152
|
+
// Do not assume the Codex/expected result is physically the third cell.
|
|
153
|
+
// The live TU Wien page is hand-edited old-style HTML and newly appended
|
|
154
|
+
// rows can have malformed/extra cells. The semantic column is explicitly
|
|
155
|
+
// marked class=codx upstream, so prefer that marker and only fall back to
|
|
156
|
+
// position 3 for small synthetic fixtures/older snapshots without classes.
|
|
157
|
+
const codexCell = cells.find((cell) => cellHasClass(cell, 'codx'));
|
|
158
|
+
if (labelledCodexPage && codexCell == null) {
|
|
159
|
+
throw new Error(`WG17 syntax row #${id} has no labelled Codex cell; upstream HTML format may have changed`);
|
|
160
|
+
}
|
|
161
|
+
const expectedCell = codexCell ?? cells[2];
|
|
162
|
+
const expected = htmlCellText(expectedCell.body).replace(/\u00a0/g, ' ').replace(/[²³°]/g, '').replace(/[ \t\n]+/g, ' ').trim();
|
|
106
163
|
if (query.length === 0 || expected.length === 0) continue;
|
|
107
|
-
|
|
164
|
+
if (seen.has(id)) {
|
|
165
|
+
const previous = byId.get(id);
|
|
166
|
+
if (previous.query !== query || previous.expected !== expected) {
|
|
167
|
+
throw new Error(`duplicate active WG17 syntax id #${id} in upstream table`);
|
|
168
|
+
}
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const item = { id, query, expected };
|
|
172
|
+
cases.push(item);
|
|
173
|
+
byId.set(id, item);
|
|
108
174
|
seen.add(id);
|
|
109
175
|
}
|
|
110
176
|
if (cases.length < 100) {
|
|
@@ -115,6 +181,13 @@ export function parseWg17SyntaxTable(html) {
|
|
|
115
181
|
`(saw ${trCount} row starts and ${tdCount} cell starts); upstream HTML format may have changed`,
|
|
116
182
|
);
|
|
117
183
|
}
|
|
184
|
+
const declared = declaredSyntaxTotal(html);
|
|
185
|
+
if (declared != null && cases.length !== declared) {
|
|
186
|
+
throw new Error(
|
|
187
|
+
`WG17 syntax inventory mismatch: upstream declares ${declared} active queries but parser discovered ${cases.length}; ` +
|
|
188
|
+
'upstream HTML format may have changed',
|
|
189
|
+
);
|
|
190
|
+
}
|
|
118
191
|
return cases;
|
|
119
192
|
}
|
|
120
193
|
|