eyelang 0.1.5 → 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/docs/guide.md CHANGED
@@ -238,7 +238,7 @@ 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. 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:
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
242
 
243
243
  | Family | Count | Built-ins |
244
244
  |---|---:|---|
@@ -289,8 +289,6 @@ Use `holds/2` when you want to match the member term directly, for example `name
289
289
 
290
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.
291
291
 
292
- 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).
293
-
294
292
 
295
293
  ## Example catalog
296
294
 
@@ -467,8 +465,8 @@ The conformance suite lives in [`test/conformance/`](../test/conformance/) as on
467
465
  Common commands:
468
466
 
469
467
  ```sh
470
- npm run test:eyelang # integration check plus eyelang corpus
471
- 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
472
470
  node test/run-conformance.mjs
473
471
  node test/run-regression.mjs
474
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.5",
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 {
@@ -254,8 +256,44 @@ why(
254
256
  ];
255
257
  }
256
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
+
257
291
  function apiCases() {
258
292
  return [
293
+ {
294
+ name: 'public type declarations match runtime exports',
295
+ run: () => assertArrayEqual(declaredValueExportNames(), runtimeExportNames(), 'public value exports'),
296
+ },
259
297
  {
260
298
  name: 'run materialization through public API without proof by default',
261
299
  run: () => {
@@ -455,6 +493,7 @@ function runSection(reporter, name, cases) {
455
493
  }
456
494
 
457
495
  function sectionLabel(name) {
496
+ if (name === 'Documentation sync') return 'documentation sync';
458
497
  if (name === 'API') return 'API';
459
498
  if (name === 'White-box') return 'white-box';
460
499
  return name.toLowerCase();
@@ -499,9 +538,9 @@ function listExampleNames() {
499
538
  .sort();
500
539
  }
501
540
 
502
- function readmeCatalogExampleNames() {
503
- const readme = fs.readFileSync(path.join(packageRoot, 'docs', 'guide.md'), 'utf8');
504
- 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');
505
544
  return [...section.matchAll(/examples\/([A-Za-z0-9_-]+)\.pl/g)]
506
545
  .map((match) => match[1])
507
546
  .filter((name, index, names) => names.indexOf(name) === index)
@@ -512,56 +551,137 @@ function registeredBuiltinNames() {
512
551
  return [...createDefaultRegistry().defs.keys()].sort();
513
552
  }
514
553
 
515
- function readmeBuiltinNames() {
516
- const readme = fs.readFileSync(path.join(packageRoot, 'README.md'), 'utf8');
517
- const section = between(readme, '### Eyelang built-ins', '## Custom built-ins');
518
- return [...section.matchAll(/`([A-Za-z_][A-Za-z0-9_]*)\/(\d+)`/g)]
519
- .map((match) => `${match[1]}/${match[2]}`)
520
- .filter((name, index, names) => names.indexOf(name) === index)
521
- .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
+ };
560
+ }
561
+
562
+ function guideBuiltinNames() {
563
+ const guide = fs.readFileSync(path.join(packageRoot, 'docs', 'guide.md'), 'utf8');
564
+ return documentedBuiltinNames(between(guide, '### Builtins', '## Aggregation helpers'));
522
565
  }
523
566
 
524
- function readmeBuiltinSummary() {
525
- const readme = fs.readFileSync(path.join(packageRoot, 'README.md'), 'utf8');
526
- const section = between(readme, '### Eyelang built-ins', '## Custom built-ins');
567
+ function guideBuiltinSummary() {
568
+ const guide = fs.readFileSync(path.join(packageRoot, 'docs', 'guide.md'), 'utf8');
569
+ const section = between(guide, '### Builtins', '## Aggregation helpers');
527
570
  const match = section.match(/currently registers (\d+) name\/arity entries across (\d+) predicate names/);
528
- if (match == null) throw new Error('README builtin summary not found');
571
+ if (match == null) throw new Error('guide builtin summary not found');
529
572
  return { entries: Number(match[1]), names: Number(match[2]) };
530
573
  }
531
574
 
532
- function playgroundExampleNames() {
533
- const html = fs.readFileSync(path.join(root, 'playground.html'), 'utf8');
534
- const match = html.match(/const EXAMPLES = \[(.*?)\];/s);
535
- if (match == null) throw new Error('playground EXAMPLES array not found');
536
- 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)]
537
602
  .map((match) => match[1])
603
+ .filter((name, index, names) => names.indexOf(name) === index)
538
604
  .sort();
539
605
  }
540
606
 
541
- function playgroundImportGraph() {
542
- const entry = path.join(root, 'playground-worker.mjs');
543
- const seen = new Set();
544
- const stack = [entry];
545
- while (stack.length > 0) {
546
- const file = stack.pop();
547
- if (seen.has(file)) continue;
548
- seen.add(file);
607
+ function missingDocumentedPackageScripts() {
608
+ const docs = documentationFiles();
609
+ const missing = [];
610
+ for (const file of docs) {
549
611
  const text = fs.readFileSync(file, 'utf8');
550
- for (const spec of moduleSpecifiers(text)) {
551
- if (spec.startsWith('node:')) throw new Error(`${path.relative(testRoot, file)} imports ${spec}`);
552
- if (!spec.startsWith('.')) continue;
553
- const next = path.resolve(path.dirname(file), spec);
554
- 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
+ }
555
621
  }
556
622
  }
557
- return [...seen].sort();
623
+ return [...new Set(missing)].sort();
558
624
  }
559
625
 
560
- function moduleSpecifiers(text) {
561
- const specs = [];
562
- for (const match of text.matchAll(/(?:import|export)\s+(?:[^'"]*?\s+from\s+)?['"]([^'"]+)['"]/g)) specs.push(match[1]);
563
- for (const match of text.matchAll(/import\(\s*['"]([^'"]+)['"]\s*\)/g)) specs.push(match[1]);
564
- 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, '-');
565
685
  }
566
686
 
567
687
  function between(text, startMarker, endMarker) {
@@ -594,6 +714,16 @@ function assertNotIncludes(actual, expected, label) {
594
714
  if (String(actual).includes(expected)) throw new Error(`${label} unexpectedly included ${format(expected)}\nactual: ${format(actual)}`);
595
715
  }
596
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
+
597
727
  function format(value) {
598
728
  return typeof value === 'string' ? JSON.stringify(value) : String(value);
599
729
  }