eyelang 0.1.4 → 0.1.6

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  # Eyelang
2
2
 
3
- -[![npm version](https://img.shields.io/npm/v/eyelang.svg)](https://www.npmjs.com/package/eyelang)
4
- -[![DOI](https://img.shields.io/badge/DOI-10.5281%2Fzenodo.20761726-blue.svg)](https://doi.org/10.5281/zenodo.20761726)
3
+ [![npm version](https://img.shields.io/npm/v/eyelang.svg)](https://www.npmjs.com/package/eyelang)
4
+ [![DOI](https://img.shields.io/badge/DOI-10.5281%2Fzenodo.20761726-blue.svg)](https://doi.org/10.5281/zenodo.20761726)
5
5
 
6
6
  Eyelang is a small logic programming language for rules, goals, answers, and proofs.
7
7
  Its source syntax is a deliberately small subset of ordinary Prolog term and Horn-clause syntax.
@@ -34,26 +34,6 @@ console.log(result.stdout);
34
34
  - [Guide](docs/guide.md)
35
35
  - [Language reference](docs/language-reference.md)
36
36
 
37
- ### Eyelang built-ins
38
-
39
- The Eyelang engine has its own built-in registry under `src/builtins/`. Built-ins are called as ordinary Eyelang predicates. See the [Eyelang language reference](docs/language-reference.md#9-standard-built-in-predicates) for the portable profile. The bundled implementation currently registers 80 name/arity entries across 78 predicate names:
40
-
41
- | Family | Count | Built-ins |
42
- |---|---:|---|
43
- | Core and host | 4 | `eq/2`, `neq/2`, `local_time/1`, `difference/3` |
44
- | Arithmetic, comparison, and generators | 29 | `neg/2`, `abs/2`, `sin/2`, `cos/2`, `tan/2`, `asin/2`, `acos/2`, `sqrt/2`, `floor/2`, `ceiling/2`, `trunc/2`, `rounded/2`, `exp/2`, `log/2`, `add/3`, `sub/3`, `mul/3`, `div/3`, `mod/3`, `min/3`, `max/3`, `pow/3`, `atan2/3`, `lt/2`, `gt/2`, `le/2`, `ge/2`, `between/3`, `smallest_divisor_from/3` |
45
- | Strings and conversions | 15 | `str_concat/3`, `contains/2`, `matches/2`, `matches/3`, `not_matches/2`, `split/3`, `join/3`, `substring/4`, `replace/4`, `lowercase/2`, `uppercase/2`, `trim/2`, `number_string/2`, `atom_string/2`, `term_string/2` |
46
- | Lists | 19 | `append/3`, `nth0/3`, `set_nth0/4`, `head/2`, `rest/2`, `last/2`, `take/3`, `drop/3`, `slice/4`, `member/2`, `select/3`, `not_member/2`, `reverse/2`, `length/2`, `sum_list/2`, `min_list/2`, `max_list/2`, `list_to_set/2`, `sort/2` |
47
- | Aggregation | 5 | `findall/3`, `countall/2`, `sumall/3`, `aggregate_min/5`, `aggregate_max/5` |
48
- | Control | 3 | `not/1`, `once/1`, `forall/2` |
49
- | Context and terms | 5 | `holds/2`, `holds/3`, `functor/3`, `arg/3`, `compound_name_arguments/3` |
50
- | **Total** | **80** | |
51
-
52
-
53
- ## Custom built-ins
54
-
55
- Custom built-ins can be supplied by creating a `BuiltinRegistry` and passing it to the solver or `run` API.
56
-
57
37
  ## Tests
58
38
 
59
39
  ```bash
package/docs/guide.md CHANGED
@@ -238,9 +238,19 @@ The playground has matching `--stats` and `--proof` checkboxes, so browser runs
238
238
 
239
239
  ### Builtins
240
240
 
241
- Eyelang builtins are registered by name and arity in small modules under [`src/builtins`](../src/builtins). This keeps the runtime portable to Node.js and the browser while giving each builtin family a clear boundary. Builtins are enabled by normal predicate calls.
241
+ Eyelang builtins are registered by name and arity in small modules under [`src/builtins`](../src/builtins). This keeps the runtime portable to Node.js and the browser while giving each builtin family a clear boundary. Built-ins are called as ordinary Eyelang predicates. See the [Eyelang language reference](language-reference.md#9-standard-built-in-predicates) for the portable profile. The bundled implementation currently registers 80 name/arity entries across 78 predicate names:
242
+
243
+ | Family | Count | Built-ins |
244
+ |---|---:|---|
245
+ | Core and host | 4 | `eq/2`, `neq/2`, `local_time/1`, `difference/3` |
246
+ | Arithmetic, comparison, and generators | 29 | `neg/2`, `abs/2`, `sin/2`, `cos/2`, `tan/2`, `asin/2`, `acos/2`, `sqrt/2`, `floor/2`, `ceiling/2`, `trunc/2`, `rounded/2`, `exp/2`, `log/2`, `add/3`, `sub/3`, `mul/3`, `div/3`, `mod/3`, `min/3`, `max/3`, `pow/3`, `atan2/3`, `lt/2`, `gt/2`, `le/2`, `ge/2`, `between/3`, `smallest_divisor_from/3` |
247
+ | Strings and conversions | 15 | `str_concat/3`, `contains/2`, `matches/2`, `matches/3`, `not_matches/2`, `split/3`, `join/3`, `substring/4`, `replace/4`, `lowercase/2`, `uppercase/2`, `trim/2`, `number_string/2`, `atom_string/2`, `term_string/2` |
248
+ | Lists | 19 | `append/3`, `nth0/3`, `set_nth0/4`, `head/2`, `rest/2`, `last/2`, `take/3`, `drop/3`, `slice/4`, `member/2`, `select/3`, `not_member/2`, `reverse/2`, `length/2`, `sum_list/2`, `min_list/2`, `max_list/2`, `list_to_set/2`, `sort/2` |
249
+ | Aggregation | 5 | `findall/3`, `countall/2`, `sumall/3`, `aggregate_min/5`, `aggregate_max/5` |
250
+ | Control | 3 | `not/1`, `once/1`, `forall/2` |
251
+ | Context and terms | 5 | `holds/2`, `holds/3`, `functor/3`, `arg/3`, `compound_name_arguments/3` |
252
+ | **Total** | **80** | |
242
253
 
243
- The builtin families cover unification, arithmetic, comparison, dates, strings, lists, aggregation, context terms, term inspection, and search control. Domain-specific number-theory and matrix helper modules were removed from the default registry because those predicates were examples/accelerators rather than a reusable portable surface. New reusable helpers cover common numeric functions, list slicing and summaries, string normalization/conversion, term inspection/construction, and `forall/2`. The complete bundled implementation list is kept in the top-level [README built-ins section](../README.md#built-ins-1), and the regression suite checks that table against the actual runtime registry.
244
254
 
245
255
  To add a builtin, create or extend a module with `register(registry)` and call `registry.add(name, arity, handler, options)`. The default registry is assembled in [`src/builtins/registry.js`](../src/builtins/registry.js). Builtins that are only safe for specific argument modes should provide a `ready` predicate and `fallbackWhenNotReady: true`, so user-defined clauses remain visible until the builtin is applicable.
246
256
 
@@ -279,8 +289,6 @@ Use `holds/2` when you want to match the member term directly, for example `name
279
289
 
280
290
  `matches/3` can create context data from named regular-expression captures, which is useful when text logs or messages need to become facts before later rules inspect them with `holds/2` or `holds/3`. See [`observability-log-correlation.pl`](../examples/observability-log-correlation.pl) for a complete log-correlation example.
281
291
 
282
- The N3 counterpart of the context schema audit lives at [`examples/context-schema-audit.n3`](../examples/context-schema-audit.n3) with golden output in [`examples/output/context-schema-audit.md`](../examples/output/context-schema-audit.md).
283
-
284
292
 
285
293
  ## Example catalog
286
294
 
@@ -457,8 +465,8 @@ The conformance suite lives in [`test/conformance/`](../test/conformance/) as on
457
465
  Common commands:
458
466
 
459
467
  ```sh
460
- npm run test:eyelang # integration check plus eyelang corpus
461
- npm run test:eyelang:corpus # conformance, regression/API/white-box, examples, and proof examples
468
+ npm run test:eyelang # alias for npm test
469
+ npm test # full conformance, regression/API/white-box, examples, and proof examples
462
470
  node test/run-conformance.mjs
463
471
  node test/run-regression.mjs
464
472
  node test/run-examples.mjs
@@ -39,7 +39,7 @@
39
39
  - [9.6 Strings and atom constants](#96-strings-and-atom-constants)
40
40
  - [9.7 Lists](#97-lists)
41
41
  - [9.8 Aggregation and ordering](#98-aggregation-and-ordering)
42
- - [9.9 Context terms](#99-context-terms)
42
+ - [9.9 Context and term inspection](#99-context-and-term-inspection)
43
43
  - [9.10 Search control](#910-search-control)
44
44
  - [10. Implementation-specific built-ins](#10-implementation-specific-built-ins)
45
45
  - [11. Declarations](#11-declarations)
@@ -475,8 +475,6 @@ The first goal can yield `holds((name(alice, "Alice"), knows(alice, bob)), name(
475
475
 
476
476
  `holds/3` is the appropriate form for schema-style introspection because it exposes the predicate name and all arguments without assuming a fixed arity. For example, a single rule can inspect `heartbeat`, `source(sensor17)`, `temperature(sensor17, 38)`, and `signature(sensor17, sha256, Hash, Time)` as `heartbeat/0`, `source/1`, `temperature/2`, and `signature/4`; see [`context-schema-audit.pl`](../examples/context-schema-audit.pl).
477
477
 
478
- The N3 example [`context-schema-audit.n3`](../examples/context-schema-audit.n3) shows the same idea in quoted graph form: members are encoded as predicates with RDF-list argument objects, then `log:includes` and `list:length` expose `Name + Arity` for schema checking.
479
-
480
478
  ### 9.10 Search control
481
479
 
482
480
  | Built-in | Meaning |
package/index.d.ts CHANGED
@@ -6,6 +6,11 @@ export interface EyelangRunOptions {
6
6
  proof?: boolean;
7
7
  why?: boolean;
8
8
  explain?: boolean;
9
+ maxDepth?: number;
10
+ solutionLimit?: number;
11
+ registry?: BuiltinRegistry;
12
+ sourceMetadata?: boolean;
13
+ markRecursive?: boolean;
9
14
  [key: string]: unknown;
10
15
  }
11
16
 
@@ -20,61 +25,194 @@ export interface EyelangSourcePart {
20
25
  filename?: string;
21
26
  }
22
27
 
28
+ export interface EyelangClause {
29
+ head: EyelangTerm;
30
+ body: EyelangTerm[];
31
+ index?: number;
32
+ filename?: string;
33
+ clauseNumber?: number;
34
+ }
35
+
36
+ export interface EyelangPredicateGroup {
37
+ name: string;
38
+ arity: number;
39
+ clauses: EyelangClause[];
40
+ argIndexes: unknown[];
41
+ pairIndexes: unknown[];
42
+ memoized: boolean;
43
+ recursive: boolean;
44
+ }
45
+
46
+ export type EyelangTerm = Term | { type: string; name: string; args?: EyelangTerm[]; arity?: number };
47
+
48
+ export class Term {
49
+ constructor(type: string, name?: unknown, args?: EyelangTerm[]);
50
+ type: string;
51
+ name: string;
52
+ args: EyelangTerm[];
53
+ get arity(): number;
54
+ }
55
+
56
+ export class Env {
57
+ constructor(bindings?: Iterable<readonly [string, EyelangTerm]> | null);
58
+ bindings: Map<string, EyelangTerm>;
59
+ clone(): Env;
60
+ has(name: string): boolean;
61
+ get(name: string): EyelangTerm | undefined;
62
+ bind(name: string, term: EyelangTerm): void;
63
+ }
64
+
23
65
  export class Program {
24
- constructor(clauses?: unknown[], options?: EyelangRunOptions);
66
+ constructor(clauses?: EyelangClause[], options?: EyelangRunOptions);
67
+ clauses: EyelangClause[];
68
+ groups: Map<string, EyelangPredicateGroup>;
69
+ materializedGroups: Set<string>;
70
+ hasMaterialize: boolean;
25
71
  static parse(source: string, options?: EyelangRunOptions): Program;
26
72
  static parseSources(sources?: Array<string | EyelangSourcePart>, options?: EyelangRunOptions): Program;
27
- materializationGoals(): unknown[];
73
+ makeGroup(name: string, arity: number): EyelangPredicateGroup;
74
+ indexClause(clause: EyelangClause): void;
75
+ findGroup(name: string, arity: number): EyelangPredicateGroup | null;
76
+ applyDeclarations(options?: EyelangRunOptions): void;
77
+ markRecursivePredicates(): void;
78
+ hasMaterializeDeclarations(): boolean;
79
+ groupIsMaterialized(group: EyelangPredicateGroup): boolean;
80
+ groupHasRule(group: EyelangPredicateGroup): boolean;
28
81
  sourceFactLines(predicateKeys?: Set<string> | null): Set<string>;
82
+ materializationGoals(): EyelangTerm[];
29
83
  }
30
84
 
31
- export function makeProgram(clauses?: unknown[], options?: EyelangRunOptions): Program;
32
- export function parseClauses(source: string, options?: EyelangRunOptions): unknown[];
33
- export function parseProgramText(source: string, options?: EyelangRunOptions): unknown[];
85
+ export interface BuiltinDefinition {
86
+ name: string;
87
+ arity: number;
88
+ handler: BuiltinHandler;
89
+ deterministic: boolean;
90
+ ready: ((solver: Solver, goal: EyelangTerm, env: Env) => boolean) | null;
91
+ fallbackWhenNotReady: boolean;
92
+ shouldUse: ((solver: Solver, goal: EyelangTerm, env: Env) => boolean) | null;
93
+ }
34
94
 
35
- export class Env {
36
- constructor(parent?: Env | null);
95
+ export type BuiltinHandler = (context: { solver: Solver; goal: EyelangTerm; env: Env }) => Iterable<Env>;
96
+
97
+ export class BuiltinRegistry {
98
+ constructor();
99
+ defs: Map<string, BuiltinDefinition>;
100
+ add(name: string, arity: number, handler: BuiltinHandler, options?: Partial<BuiltinDefinition>): this;
101
+ get(name: string, arity: number): BuiltinDefinition | null;
37
102
  }
38
103
 
39
104
  export class Solver {
40
105
  constructor(program: Program, options?: EyelangRunOptions);
106
+ program: Program;
107
+ registry: BuiltinRegistry;
108
+ maxDepth: number;
109
+ solutionLimit: number;
110
+ solutionsSeen: number;
111
+ active: unknown[];
112
+ memo: Map<string, unknown>;
41
113
  stats: EyelangStats;
42
- solve(goals: unknown[], env?: Env, depth?: number): Iterable<Env>;
114
+ cloneForInnerGoal(solutionLimit?: number): Solver;
115
+ solve(goals: EyelangTerm | EyelangTerm[], env?: Env, depth?: number): Iterable<Env>;
116
+ activeVariant(goal: EyelangTerm, env: Env): boolean;
43
117
  }
44
118
 
45
- export class BuiltinRegistry {
46
- constructor();
47
- }
119
+ export const VAR: 'var';
120
+ export const ATOM: 'atom';
121
+ export const STRING: 'string';
122
+ export const NUMBER: 'number';
123
+ export const COMPOUND: 'compound';
48
124
 
125
+ export function variable(name: string): Term;
126
+ export function atom(name: string): Term;
127
+ export function stringTerm(value: string): Term;
128
+ export function numberTerm(value: string | number): Term;
129
+ export function compound(name: string, args?: EyelangTerm[]): Term;
130
+ export function emptyList(): Term;
131
+ export function cons(head: EyelangTerm, tail: EyelangTerm): Term;
132
+ export function deref(term: EyelangTerm, env: Env): EyelangTerm;
133
+ export function isScalar(term: EyelangTerm | null | undefined): boolean;
134
+ export function isEmptyList(term: EyelangTerm | null | undefined): boolean;
135
+ export function isCons(term: EyelangTerm | null | undefined): boolean;
136
+ export function isConjunction(term: EyelangTerm | null | undefined): boolean;
137
+ export function unify(left: EyelangTerm, right: EyelangTerm, env: Env): boolean;
138
+ export function cloneTerm(term: EyelangTerm): Term;
139
+ export function freshTerm(term: EyelangTerm, suffix: string | number): Term;
140
+ export function copyResolved(term: EyelangTerm, env: Env): Term;
141
+ export function termIsGround(term: EyelangTerm, env?: Env): boolean;
142
+ export function termToString(term: EyelangTerm, env?: Env, quoteStrings?: boolean): string;
143
+ export function lexicalValue(term: EyelangTerm, env: Env): string | null;
144
+ export function properListItems(list: EyelangTerm, env: Env): EyelangTerm[] | null;
145
+ export function listFromItems(items: EyelangTerm[], start?: number, end?: number, tail?: EyelangTerm): Term;
146
+ export function flattenConjunction(goal: EyelangTerm): EyelangTerm[];
147
+ export function termSignature(term: EyelangTerm | null | undefined): string | null;
148
+ export function variantTerms(left: EyelangTerm, leftEnv: Env, right: EyelangTerm, rightEnv: Env, pairs?: Map<string, string>, reverse?: Map<string, string>): boolean;
149
+ export function compareTerms(left: EyelangTerm, right: EyelangTerm): number;
150
+ export function isDecimalInteger(text: string | null | undefined): boolean;
151
+ export function compareIntegerText(left: string, right: string): number;
152
+ export function parseFiniteNumber(text: string | null | undefined): number | null;
153
+ export function numberTextFromDouble(value: number): string | null;
154
+ export function compareNumberText(left: string, right: string): number;
155
+
156
+ export function makeProgram(source: string, options?: EyelangRunOptions): Program;
157
+ export function parseClauses(source: string, options?: EyelangRunOptions): EyelangClause[];
158
+ export function parseProgramText(source: string, options?: EyelangRunOptions): EyelangClause[];
49
159
  export function createDefaultRegistry(): BuiltinRegistry;
50
160
  export function getDefaultRegistry(): BuiltinRegistry;
51
-
52
161
  export function run(source: string | Program, options?: EyelangRunOptions): EyelangRunResult;
53
-
54
- export function whyProof(program: Program, goal: unknown, options?: EyelangRunOptions): { ok: boolean; text: string };
55
- export function whyNoProof(goal: unknown): string;
56
-
57
- export const ATOM: 'atom';
58
- export const VARIABLE: 'variable';
59
- export const COMPOUND: 'compound';
60
- export const NUMBER: 'number';
61
- export const STRING: 'string';
62
- export function atom(name: string): unknown;
63
- export function variable(name: string): unknown;
64
- export function compound(name: string, args?: unknown[]): unknown;
65
- export function termToString(term: unknown, env?: Env, quoted?: boolean): string;
162
+ export function whyProof(program: Program, goal: EyelangTerm, options?: EyelangRunOptions): { ok: boolean; text: string };
163
+ export function whyNoProof(goal: EyelangTerm): string;
164
+ export function explainProof(program: Program, goal: EyelangTerm, options?: EyelangRunOptions): { ok: boolean; text: string };
66
165
 
67
166
  declare const eyelang: {
68
- run: typeof run;
167
+ VAR: typeof VAR;
168
+ ATOM: typeof ATOM;
169
+ STRING: typeof STRING;
170
+ NUMBER: typeof NUMBER;
171
+ COMPOUND: typeof COMPOUND;
172
+ Term: typeof Term;
173
+ Env: typeof Env;
69
174
  Program: typeof Program;
175
+ Solver: typeof Solver;
176
+ BuiltinRegistry: typeof BuiltinRegistry;
177
+ variable: typeof variable;
178
+ atom: typeof atom;
179
+ stringTerm: typeof stringTerm;
180
+ numberTerm: typeof numberTerm;
181
+ compound: typeof compound;
182
+ emptyList: typeof emptyList;
183
+ cons: typeof cons;
184
+ deref: typeof deref;
185
+ isScalar: typeof isScalar;
186
+ isEmptyList: typeof isEmptyList;
187
+ isCons: typeof isCons;
188
+ isConjunction: typeof isConjunction;
189
+ unify: typeof unify;
190
+ cloneTerm: typeof cloneTerm;
191
+ freshTerm: typeof freshTerm;
192
+ copyResolved: typeof copyResolved;
193
+ termIsGround: typeof termIsGround;
194
+ termToString: typeof termToString;
195
+ lexicalValue: typeof lexicalValue;
196
+ properListItems: typeof properListItems;
197
+ listFromItems: typeof listFromItems;
198
+ flattenConjunction: typeof flattenConjunction;
199
+ termSignature: typeof termSignature;
200
+ variantTerms: typeof variantTerms;
201
+ compareTerms: typeof compareTerms;
202
+ isDecimalInteger: typeof isDecimalInteger;
203
+ compareIntegerText: typeof compareIntegerText;
204
+ parseFiniteNumber: typeof parseFiniteNumber;
205
+ numberTextFromDouble: typeof numberTextFromDouble;
206
+ compareNumberText: typeof compareNumberText;
70
207
  makeProgram: typeof makeProgram;
71
208
  parseClauses: typeof parseClauses;
72
209
  parseProgramText: typeof parseProgramText;
73
- Solver: typeof Solver;
74
- Env: typeof Env;
75
- BuiltinRegistry: typeof BuiltinRegistry;
76
210
  createDefaultRegistry: typeof createDefaultRegistry;
77
211
  getDefaultRegistry: typeof getDefaultRegistry;
212
+ run: typeof run;
213
+ whyProof: typeof whyProof;
214
+ whyNoProof: typeof whyNoProof;
215
+ explainProof: typeof explainProof;
78
216
  };
79
217
 
80
218
  export default eyelang;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eyelang",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "A small Prolog-syntax-subset logic programming language for rules, goals, answers, and proofs.",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -8,6 +8,7 @@ import os from 'node:os';
8
8
  import path from 'node:path';
9
9
  import { spawnSync } from 'node:child_process';
10
10
  import { fileURLToPath } from 'node:url';
11
+ import * as publicApi from '../src/index.js';
11
12
  import {
12
13
  run,
13
14
  Program,
@@ -50,6 +51,7 @@ export function runRegression(reporter = new TestReporter()) {
50
51
 
51
52
  try {
52
53
  runSection(reporter, 'Regression', regressionCases());
54
+ runSection(reporter, 'Documentation sync', documentationSyncCases());
53
55
  runSection(reporter, 'API', apiCases());
54
56
  runSection(reporter, 'White-box', whiteBoxCases());
55
57
  } finally {
@@ -188,26 +190,6 @@ why(
188
190
  assertEqual(result.stderr, '', 'stderr');
189
191
  },
190
192
  },
191
- {
192
- name: 'README covers every mirrored example',
193
- run: () => {
194
- const examples = listExampleNames();
195
- const readmeExamples = readmeCatalogExampleNames();
196
- assertEqual(readmeExamples.join('\n'), examples.join('\n'), 'README example catalog');
197
- },
198
- },
199
- {
200
- name: 'README mirrors the Eyelang builtin registry',
201
- run: () => {
202
- const actual = registeredBuiltinNames();
203
- const documented = readmeBuiltinNames();
204
- assertEqual(documented.join('\n'), actual.join('\n'), 'README builtin catalog');
205
-
206
- const { entries, names } = readmeBuiltinSummary();
207
- assertEqual(entries, actual.length, 'README builtin entry count');
208
- assertEqual(names, new Set(actual.map((item) => item.split('/')[0])).size, 'README builtin name count');
209
- },
210
- },
211
193
  {
212
194
  name: 'stdin input is accepted',
213
195
  run: () => {
@@ -274,8 +256,44 @@ why(
274
256
  ];
275
257
  }
276
258
 
259
+
260
+ function documentationSyncCases() {
261
+ return [
262
+ {
263
+ name: 'language reference builtins match runtime registry',
264
+ run: () => assertArrayEqual(languageReferenceBuiltinNames(), registeredBuiltinNames(), 'builtins'),
265
+ },
266
+ {
267
+ name: 'guide builtin catalog matches runtime registry',
268
+ run: () => {
269
+ assertArrayEqual(guideBuiltinNames(), registeredBuiltinNames(), 'builtins');
270
+ const summary = guideBuiltinSummary();
271
+ const actual = registeredBuiltinSummary();
272
+ assertEqual(summary.entries, actual.entries, 'builtin entry count');
273
+ assertEqual(summary.names, actual.names, 'builtin predicate name count');
274
+ },
275
+ },
276
+ {
277
+ name: 'guide example catalog matches examples directory',
278
+ run: () => assertArrayEqual(guideCatalogExampleNames(), listExampleNames(), 'example catalog'),
279
+ },
280
+ {
281
+ name: 'documentation local links and anchors resolve',
282
+ run: () => assertArrayEqual(findBrokenDocLinks(), [], 'broken documentation links'),
283
+ },
284
+ {
285
+ name: 'documented npm scripts exist in package.json',
286
+ run: () => assertArrayEqual(missingDocumentedPackageScripts(), [], 'missing documented npm scripts'),
287
+ },
288
+ ];
289
+ }
290
+
277
291
  function apiCases() {
278
292
  return [
293
+ {
294
+ name: 'public type declarations match runtime exports',
295
+ run: () => assertArrayEqual(declaredValueExportNames(), runtimeExportNames(), 'public value exports'),
296
+ },
279
297
  {
280
298
  name: 'run materialization through public API without proof by default',
281
299
  run: () => {
@@ -475,6 +493,7 @@ function runSection(reporter, name, cases) {
475
493
  }
476
494
 
477
495
  function sectionLabel(name) {
496
+ if (name === 'Documentation sync') return 'documentation sync';
478
497
  if (name === 'API') return 'API';
479
498
  if (name === 'White-box') return 'white-box';
480
499
  return name.toLowerCase();
@@ -519,9 +538,9 @@ function listExampleNames() {
519
538
  .sort();
520
539
  }
521
540
 
522
- function readmeCatalogExampleNames() {
523
- const readme = fs.readFileSync(path.join(packageRoot, 'docs', 'guide.md'), 'utf8');
524
- const section = between(readme, '## Example catalog', '## Golden outputs, tests, and conformance');
541
+ function guideCatalogExampleNames() {
542
+ const guide = fs.readFileSync(path.join(packageRoot, 'docs', 'guide.md'), 'utf8');
543
+ const section = between(guide, '## Example catalog', '## Golden outputs, tests, and conformance');
525
544
  return [...section.matchAll(/examples\/([A-Za-z0-9_-]+)\.pl/g)]
526
545
  .map((match) => match[1])
527
546
  .filter((name, index, names) => names.indexOf(name) === index)
@@ -532,56 +551,137 @@ function registeredBuiltinNames() {
532
551
  return [...createDefaultRegistry().defs.keys()].sort();
533
552
  }
534
553
 
535
- function readmeBuiltinNames() {
536
- const readme = fs.readFileSync(path.join(packageRoot, 'README.md'), 'utf8');
537
- const section = between(readme, '### Eyelang built-ins', '## Custom built-ins');
538
- return [...section.matchAll(/`([A-Za-z_][A-Za-z0-9_]*)\/(\d+)`/g)]
539
- .map((match) => `${match[1]}/${match[2]}`)
540
- .filter((name, index, names) => names.indexOf(name) === index)
541
- .sort();
554
+ function registeredBuiltinSummary() {
555
+ const names = registeredBuiltinNames();
556
+ return {
557
+ entries: names.length,
558
+ names: new Set(names.map((name) => name.split('/')[0])).size,
559
+ };
542
560
  }
543
561
 
544
- function readmeBuiltinSummary() {
545
- const readme = fs.readFileSync(path.join(packageRoot, 'README.md'), 'utf8');
546
- const section = between(readme, '### Eyelang built-ins', '## Custom built-ins');
562
+ function guideBuiltinNames() {
563
+ const guide = fs.readFileSync(path.join(packageRoot, 'docs', 'guide.md'), 'utf8');
564
+ return documentedBuiltinNames(between(guide, '### Builtins', '## Aggregation helpers'));
565
+ }
566
+
567
+ function guideBuiltinSummary() {
568
+ const guide = fs.readFileSync(path.join(packageRoot, 'docs', 'guide.md'), 'utf8');
569
+ const section = between(guide, '### Builtins', '## Aggregation helpers');
547
570
  const match = section.match(/currently registers (\d+) name\/arity entries across (\d+) predicate names/);
548
- if (match == null) throw new Error('README builtin summary not found');
571
+ if (match == null) throw new Error('guide builtin summary not found');
549
572
  return { entries: Number(match[1]), names: Number(match[2]) };
550
573
  }
551
574
 
552
- function playgroundExampleNames() {
553
- const html = fs.readFileSync(path.join(root, 'playground.html'), 'utf8');
554
- const match = html.match(/const EXAMPLES = \[(.*?)\];/s);
555
- if (match == null) throw new Error('playground EXAMPLES array not found');
556
- return [...match[1].matchAll(/"([^"]+)"/g)]
575
+ function languageReferenceBuiltinNames() {
576
+ const reference = fs.readFileSync(path.join(packageRoot, 'docs', 'language-reference.md'), 'utf8');
577
+ return documentedBuiltinNames(between(reference, '## 9. Standard built-in predicates', '## 10. Implementation-specific built-ins'));
578
+ }
579
+
580
+ function documentedBuiltinNames(section) {
581
+ const names = [];
582
+ for (const line of section.split('\n')) {
583
+ if (!line.trim().startsWith('|') || !line.includes('`')) continue;
584
+ for (const match of line.matchAll(/`([A-Za-z_][A-Za-z0-9_]*)\(([^`)]*)\)`/g)) {
585
+ const arity = match[2].trim() === '' ? 0 : match[2].split(',').length;
586
+ names.push(`${match[1]}/${arity}`);
587
+ }
588
+ for (const match of line.matchAll(/`([A-Za-z_][A-Za-z0-9_]*)\/(\d+)`/g)) {
589
+ names.push(`${match[1]}/${match[2]}`);
590
+ }
591
+ }
592
+ return [...new Set(names)].sort();
593
+ }
594
+
595
+ function runtimeExportNames() {
596
+ return Object.keys(publicApi).sort();
597
+ }
598
+
599
+ function declaredValueExportNames() {
600
+ const dts = fs.readFileSync(path.join(packageRoot, 'index.d.ts'), 'utf8');
601
+ return [...dts.matchAll(/^export\s+(?:declare\s+)?(?:class|function|const)\s+([A-Za-z_][A-Za-z0-9_]*)/gm)]
557
602
  .map((match) => match[1])
603
+ .filter((name, index, names) => names.indexOf(name) === index)
558
604
  .sort();
559
605
  }
560
606
 
561
- function playgroundImportGraph() {
562
- const entry = path.join(root, 'playground-worker.mjs');
563
- const seen = new Set();
564
- const stack = [entry];
565
- while (stack.length > 0) {
566
- const file = stack.pop();
567
- if (seen.has(file)) continue;
568
- seen.add(file);
607
+ function missingDocumentedPackageScripts() {
608
+ const docs = documentationFiles();
609
+ const missing = [];
610
+ for (const file of docs) {
569
611
  const text = fs.readFileSync(file, 'utf8');
570
- for (const spec of moduleSpecifiers(text)) {
571
- if (spec.startsWith('node:')) throw new Error(`${path.relative(testRoot, file)} imports ${spec}`);
572
- if (!spec.startsWith('.')) continue;
573
- const next = path.resolve(path.dirname(file), spec);
574
- if (next.startsWith(root) && fs.existsSync(next)) stack.push(next);
612
+ for (const line of text.split('\n')) {
613
+ const trimmed = line.trim();
614
+ if (!trimmed.startsWith('npm ') && !line.includes('`npm ')) continue;
615
+ for (const match of line.matchAll(/\bnpm\s+(?:run\s+)?([A-Za-z0-9:_-]+)/g)) {
616
+ const command = match[1];
617
+ if (command === 'install') continue;
618
+ const script = command === 'test' ? 'test' : command;
619
+ if (!pkg.scripts?.[script]) missing.push(`${path.relative(packageRoot, file)}: npm ${command === 'test' ? 'test' : `run ${script}`}`);
620
+ }
575
621
  }
576
622
  }
577
- return [...seen].sort();
623
+ return [...new Set(missing)].sort();
578
624
  }
579
625
 
580
- function moduleSpecifiers(text) {
581
- const specs = [];
582
- for (const match of text.matchAll(/(?:import|export)\s+(?:[^'"]*?\s+from\s+)?['"]([^'"]+)['"]/g)) specs.push(match[1]);
583
- for (const match of text.matchAll(/import\(\s*['"]([^'"]+)['"]\s*\)/g)) specs.push(match[1]);
584
- return specs;
626
+ function findBrokenDocLinks() {
627
+ const broken = [];
628
+ const anchorsByFile = new Map();
629
+ for (const file of documentationFiles()) {
630
+ const text = fs.readFileSync(file, 'utf8');
631
+ for (const target of markdownLinkTargets(text)) {
632
+ if (/^(?:https?:|mailto:)/i.test(target)) continue;
633
+ const [targetPathRaw, fragmentRaw] = target.split('#');
634
+ const targetPath = targetPathRaw === '' ? file : path.resolve(path.dirname(file), decodeURI(targetPathRaw));
635
+ const display = `${path.relative(packageRoot, file)} -> ${target}`;
636
+ if (!fs.existsSync(targetPath)) {
637
+ broken.push(`${display} (missing target)`);
638
+ continue;
639
+ }
640
+ if (fragmentRaw != null && fragmentRaw !== '') {
641
+ const anchors = anchorsByFile.get(targetPath) ?? markdownAnchors(targetPath);
642
+ anchorsByFile.set(targetPath, anchors);
643
+ if (!anchors.has(fragmentRaw)) broken.push(`${display} (missing heading #${fragmentRaw})`);
644
+ }
645
+ }
646
+ }
647
+ return broken.sort();
648
+ }
649
+
650
+ function documentationFiles() {
651
+ return [
652
+ path.join(packageRoot, 'README.md'),
653
+ path.join(packageRoot, 'docs', 'guide.md'),
654
+ path.join(packageRoot, 'docs', 'language-reference.md'),
655
+ ];
656
+ }
657
+
658
+ function markdownLinkTargets(text) {
659
+ return [...text.matchAll(/!?\[[^\]\n]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g)].map((match) => match[1]);
660
+ }
661
+
662
+ function markdownAnchors(file) {
663
+ if (!file.endsWith('.md')) return new Set();
664
+ const text = fs.readFileSync(file, 'utf8');
665
+ const anchors = new Set();
666
+ const counts = new Map();
667
+ for (const match of text.matchAll(/^#{1,6}\s+(.+)$/gm)) {
668
+ const base = githubSlug(match[1]);
669
+ const count = counts.get(base) ?? 0;
670
+ counts.set(base, count + 1);
671
+ anchors.add(count === 0 ? base : `${base}-${count}`);
672
+ }
673
+ return anchors;
674
+ }
675
+
676
+ function githubSlug(heading) {
677
+ return heading
678
+ .replace(/`([^`]*)`/g, '$1')
679
+ .replace(/<[^>]+>/g, '')
680
+ .trim()
681
+ .toLowerCase()
682
+ .replace(/[^\p{Letter}\p{Number}\s-]/gu, '')
683
+ .trim()
684
+ .replace(/\s+/g, '-');
585
685
  }
586
686
 
587
687
  function between(text, startMarker, endMarker) {
@@ -614,6 +714,16 @@ function assertNotIncludes(actual, expected, label) {
614
714
  if (String(actual).includes(expected)) throw new Error(`${label} unexpectedly included ${format(expected)}\nactual: ${format(actual)}`);
615
715
  }
616
716
 
717
+ function assertArrayEqual(actual, expected, label) {
718
+ const actualText = actual.join('\n');
719
+ const expectedText = expected.join('\n');
720
+ if (actualText !== expectedText) {
721
+ const onlyActual = actual.filter((item) => !expected.includes(item));
722
+ const onlyExpected = expected.filter((item) => !actual.includes(item));
723
+ throw new Error(`${label} mismatch\nonly actual: ${format(onlyActual)}\nonly expected: ${format(onlyExpected)}`);
724
+ }
725
+ }
726
+
617
727
  function format(value) {
618
728
  return typeof value === 'string' ? JSON.stringify(value) : String(value);
619
729
  }