aontu 0.45.1 → 0.48.0

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.
Files changed (68) hide show
  1. package/dist/aontu.js +5 -0
  2. package/dist/aontu.js.map +1 -1
  3. package/dist/cli.d.ts +9 -0
  4. package/dist/cli.js +203 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/err.js +1 -1
  7. package/dist/err.js.map +1 -1
  8. package/dist/hints.js +1 -0
  9. package/dist/hints.js.map +1 -1
  10. package/dist/lang.d.ts +1 -1
  11. package/dist/lang.js +45 -18
  12. package/dist/lang.js.map +1 -1
  13. package/dist/lsp-server.d.ts +14 -0
  14. package/dist/lsp-server.js +87 -0
  15. package/dist/lsp-server.js.map +1 -0
  16. package/dist/lsp.d.ts +71 -0
  17. package/dist/lsp.js +375 -0
  18. package/dist/lsp.js.map +1 -0
  19. package/dist/tsconfig.tsbuildinfo +1 -1
  20. package/dist/type.d.ts +1 -1
  21. package/dist/unify.js +31 -4
  22. package/dist/unify.js.map +1 -1
  23. package/dist/utility.js +9 -2
  24. package/dist/utility.js.map +1 -1
  25. package/dist/val/BagVal.js +20 -2
  26. package/dist/val/BagVal.js.map +1 -1
  27. package/dist/val/DisjunctVal.js +25 -16
  28. package/dist/val/DisjunctVal.js.map +1 -1
  29. package/dist/val/IntegerVal.js +1 -0
  30. package/dist/val/IntegerVal.js.map +1 -1
  31. package/dist/val/LowerFuncVal.d.ts +1 -1
  32. package/dist/val/LowerFuncVal.js +1 -1
  33. package/dist/val/LowerFuncVal.js.map +1 -1
  34. package/dist/val/MapVal.js +127 -16
  35. package/dist/val/MapVal.js.map +1 -1
  36. package/dist/val/NumberVal.js +7 -2
  37. package/dist/val/NumberVal.js.map +1 -1
  38. package/dist/val/OpBaseVal.d.ts +1 -1
  39. package/dist/val/OpBaseVal.js +1 -1
  40. package/dist/val/OpBaseVal.js.map +1 -1
  41. package/dist/val/ScalarVal.js +3 -1
  42. package/dist/val/ScalarVal.js.map +1 -1
  43. package/dist/val/Val.d.ts +2 -0
  44. package/dist/val/Val.js +16 -1
  45. package/dist/val/Val.js.map +1 -1
  46. package/dist/val/VarVal.js +5 -3
  47. package/dist/val/VarVal.js.map +1 -1
  48. package/package.json +25 -13
  49. package/src/aontu.ts +5 -0
  50. package/src/cli.ts +193 -0
  51. package/src/err.ts +1 -1
  52. package/src/hints.ts +2 -0
  53. package/src/lang.ts +46 -18
  54. package/src/lsp-server.ts +109 -0
  55. package/src/lsp.ts +486 -0
  56. package/src/type.ts +1 -1
  57. package/src/unify.ts +33 -4
  58. package/src/utility.ts +11 -2
  59. package/src/val/BagVal.ts +22 -2
  60. package/src/val/DisjunctVal.ts +27 -18
  61. package/src/val/IntegerVal.ts +1 -0
  62. package/src/val/LowerFuncVal.ts +1 -1
  63. package/src/val/MapVal.ts +138 -23
  64. package/src/val/NumberVal.ts +7 -2
  65. package/src/val/OpBaseVal.ts +1 -1
  66. package/src/val/ScalarVal.ts +3 -1
  67. package/src/val/Val.ts +18 -1
  68. package/src/val/VarVal.ts +6 -4
package/src/lsp.ts ADDED
@@ -0,0 +1,486 @@
1
+ /* Copyright (c) 2025 Richard Rodger, MIT License */
2
+
3
+ // Aontu Language Server library.
4
+ //
5
+ // This module is the reusable, transport-free core of the language
6
+ // server, deliberately split from the process that serves it:
7
+ //
8
+ // - `computeDiagnostics(src)` turns Aontu source into LSP diagnostics.
9
+ // - `LspHandler` implements the LSP message flow (document sync ->
10
+ // publishDiagnostics) over decoded JSON-RPC *objects*, with no
11
+ // stdin/stdout. It is fully unit-testable and embeddable.
12
+ // - the server (`lsp-server.ts`) is a thin stdio JSON-RPC loop that
13
+ // frames bytes and feeds decoded messages to an `LspHandler`.
14
+ //
15
+ // The Go port mirrors this split in go/lsp (library) and
16
+ // go/cmd/aontu-lsp (server). Diagnostic messages are built the same way
17
+ // in both so editors see identical text regardless of implementation.
18
+
19
+ import type { Val } from './type'
20
+
21
+ import { Aontu } from './aontu'
22
+ import { getHint } from './err'
23
+
24
+
25
+ // LSP DiagnosticSeverity subset.
26
+ const SEVERITY_ERROR = 1
27
+ const SEVERITY_WARNING = 2
28
+ const SEVERITY_INFORMATION = 3
29
+ const SEVERITY_HINT = 4
30
+
31
+ // Reported to the client in the initialize response.
32
+ const LSP_VERSION = '0.1.0'
33
+
34
+
35
+ // Zero-based line / UTF-16 character offset (LSP Position).
36
+ type Position = { line: number, character: number }
37
+
38
+ // Inclusive-start, exclusive-end span (LSP Range).
39
+ type Range = { start: Position, end: Position }
40
+
41
+ // A single LSP diagnostic.
42
+ type Diagnostic = {
43
+ range: Range
44
+ severity: number
45
+ code?: string
46
+ source: string
47
+ message: string
48
+ }
49
+
50
+
51
+ // Incoming JSON-RPC message (request or notification).
52
+ type Message = {
53
+ jsonrpc?: string
54
+ id?: number | string | null
55
+ method?: string
56
+ params?: any
57
+ }
58
+
59
+ // Outgoing JSON-RPC message (response or notification).
60
+ type OutMessage = {
61
+ jsonrpc: string
62
+ id?: number | string | null
63
+ method?: string
64
+ params?: any
65
+ result?: any
66
+ error?: { code: number, message: string }
67
+ }
68
+
69
+
70
+ // Compute LSP diagnostics for a unit of Aontu source. A valid document —
71
+ // including a non-concrete schema such as `a:string` — returns an empty
72
+ // array; only genuine errors (conflicts, unresolved references, unknown
73
+ // functions, syntax errors) produce diagnostics.
74
+ function computeDiagnostics(
75
+ src: string,
76
+ opts?: { vars?: Record<string, Val> }
77
+ ): Diagnostic[] {
78
+ const aontu = new Aontu()
79
+
80
+ let root: any
81
+ try {
82
+ const ac = aontu.ctx({ collect: true })
83
+ if (opts?.vars) {
84
+ Object.assign(ac.vars, opts.vars)
85
+ }
86
+ root = aontu.unify(src, { collect: true }, ac)
87
+ }
88
+ catch (err: any) {
89
+ // Hard parse/syntax failure: report a single diagnostic. jsonic
90
+ // errors may carry 1-based line/col; fall back to the document start.
91
+ return [parseErrorDiagnostic(err)]
92
+ }
93
+
94
+ const nils: any[] = []
95
+ walkNils(root, nils, new Set())
96
+
97
+ return nils.map(nilToDiagnostic)
98
+ }
99
+
100
+
101
+ // Walk a unified Val tree collecting every NilVal exactly once. A NilVal
102
+ // in the result always represents an error; valid non-concrete values
103
+ // (scalar kinds, refs, conjuncts) are never NilVals.
104
+ function walkNils(v: any, out: any[], seen: Set<any>) {
105
+ if (null == v || 'object' !== typeof v || true !== v.isVal) return
106
+ if (seen.has(v)) return
107
+ seen.add(v)
108
+
109
+ if (v.isNil) {
110
+ out.push(v)
111
+ return
112
+ }
113
+
114
+ const peg = v.peg
115
+ if (Array.isArray(peg)) {
116
+ for (const c of peg) walkNils(c, out, seen)
117
+ }
118
+ else if (null != peg && 'object' === typeof peg) {
119
+ for (const k in peg) walkNils(peg[k], out, seen)
120
+ }
121
+
122
+ // Spread constraints live off-peg on Map/List Vals.
123
+ const spreadCj = v.spread?.cj
124
+ if (spreadCj) walkNils(spreadCj, out, seen)
125
+ }
126
+
127
+
128
+ // Convert a NilVal (1-based site row/col) to an LSP diagnostic (0-based
129
+ // line/character).
130
+ function nilToDiagnostic(nil: any): Diagnostic {
131
+ const row = nil.site?.row ?? -1
132
+ const col = nil.site?.col ?? -1
133
+
134
+ let start: Position
135
+ let end: Position
136
+ if (row >= 1 && col >= 1) {
137
+ start = { line: row - 1, character: col - 1 }
138
+ const len = labelLength(nil)
139
+ end = { line: row - 1, character: col - 1 + len }
140
+ }
141
+ else {
142
+ start = { line: 0, character: 0 }
143
+ end = { line: 0, character: 1 }
144
+ }
145
+
146
+ return {
147
+ range: { start, end },
148
+ severity: SEVERITY_ERROR,
149
+ code: nil.why,
150
+ source: 'aontu',
151
+ message: nilMessage(nil),
152
+ }
153
+ }
154
+
155
+
156
+ // Length (UTF-16 units, like LSP characters) of the offending value's
157
+ // canonical form, used to size the diagnostic range (minimum 1).
158
+ function labelLength(nil: any): number {
159
+ const c = nil.primary?.canon
160
+ return 'string' === typeof c && c.length > 0 ? c.length : 1
161
+ }
162
+
163
+
164
+ // Build the human-readable message. Kept identical to the Go port's
165
+ // NilVal.Message() (go/val.go) so diagnostics match across
166
+ // implementations: "Cannot <attempt> value: X with value: Y\n<hint>".
167
+ function nilMessage(nil: any): string {
168
+ if (nil.msg) return nil.msg
169
+
170
+ const attempt = nil.attempt ?? (null == nil.secondary ? 'resolve' : 'unify')
171
+ let msg = 'Cannot ' + attempt + ' value'
172
+ if (null != nil.primary) {
173
+ msg += ': ' + nil.primary.canon
174
+ if (null != nil.secondary) {
175
+ msg += ' with value: ' + nil.secondary.canon
176
+ }
177
+ }
178
+ const hint = getHint(nil.why, nil.details)
179
+ if (hint) {
180
+ msg += '\n' + hint
181
+ }
182
+ return msg
183
+ }
184
+
185
+
186
+ function parseErrorDiagnostic(err: any): Diagnostic {
187
+ // jsonic/AontuError may expose 1-based line/col.
188
+ const row = err?.lineNumber ?? err?.row ?? err?.line ?? -1
189
+ const col = err?.column ?? err?.col ?? -1
190
+ const start: Position = row >= 1 && col >= 1
191
+ ? { line: row - 1, character: col - 1 }
192
+ : { line: 0, character: 0 }
193
+ return {
194
+ range: { start, end: { line: start.line, character: start.character + 1 } },
195
+ severity: SEVERITY_ERROR,
196
+ code: 'parse',
197
+ source: 'aontu',
198
+ message: ('string' === typeof err?.message ? err.message : String(err)),
199
+ }
200
+ }
201
+
202
+
203
+ // initialize result: advertise full-text document sync feeding diagnostics.
204
+ function initializeResult() {
205
+ return {
206
+ capabilities: {
207
+ // 1 = TextDocumentSyncKind.Full
208
+ textDocumentSync: 1,
209
+ hoverProvider: true,
210
+ completionProvider: {},
211
+ },
212
+ serverInfo: {
213
+ name: 'aontu-lsp',
214
+ version: LSP_VERSION,
215
+ },
216
+ }
217
+ }
218
+
219
+
220
+ function publishDiagnosticsMsg(uri: string, diagnostics: Diagnostic[]): OutMessage {
221
+ return {
222
+ jsonrpc: '2.0',
223
+ method: 'textDocument/publishDiagnostics',
224
+ params: { uri, diagnostics },
225
+ }
226
+ }
227
+
228
+
229
+ // --- Hover ------------------------------------------------------------
230
+
231
+ type MarkupContent = { kind: 'markdown' | 'plaintext', value: string }
232
+ type Hover = { contents: MarkupContent, range?: Range }
233
+
234
+
235
+ // Resolve the value under the cursor and describe it. Returns null when
236
+ // the position is not over a value with a known source location. Because
237
+ // hover reads the *unified* tree, a literal shows its resolved value and
238
+ // kind (e.g. a reference target resolves to the value it points at).
239
+ function computeHover(src: string, position: Position): Hover | null {
240
+ let root: any
241
+ try {
242
+ root = new Aontu().unify(src, { collect: true })
243
+ }
244
+ catch {
245
+ return null
246
+ }
247
+
248
+ const cands: { val: any, line: number, start: number, end: number }[] = []
249
+ collectHoverCandidates(root, cands, new Set())
250
+
251
+ let best: { val: any, line: number, start: number, end: number } | null = null
252
+ for (const c of cands) {
253
+ if (c.line === position.line &&
254
+ c.start <= position.character && position.character < c.end) {
255
+ // Most specific (smallest) span wins.
256
+ if (null == best || (c.end - c.start) < (best.end - best.start)) best = c
257
+ }
258
+ }
259
+ if (null == best) return null
260
+
261
+ return {
262
+ contents: { kind: 'markdown', value: hoverMarkdown(best.val) },
263
+ range: {
264
+ start: { line: best.line, character: best.start },
265
+ end: { line: best.line, character: best.end },
266
+ },
267
+ }
268
+ }
269
+
270
+
271
+ function collectHoverCandidates(
272
+ v: any,
273
+ out: { val: any, line: number, start: number, end: number }[],
274
+ seen: Set<any>,
275
+ ) {
276
+ if (null == v || 'object' !== typeof v || true !== v.isVal) return
277
+ if (seen.has(v)) return
278
+ seen.add(v)
279
+
280
+ const row = v.site?.row ?? -1
281
+ const col = v.site?.col ?? -1
282
+ let canon = ''
283
+ try { canon = v.canon } catch { canon = '' }
284
+ // Hover targets concrete values (scalars, kinds, refs, …), not
285
+ // containers: a map/list source span is not reliably reconstructable
286
+ // from a single site, and the same restriction in the Go port keeps
287
+ // hover behaviour identical across implementations. The walk still
288
+ // recurses into containers below to reach their leaf values. Canon is
289
+ // single-line, so its length approximates the on-line source span.
290
+ if (row >= 1 && col >= 1 && canon.length > 0 && !canon.includes('\n') &&
291
+ !v.isMap && !v.isList) {
292
+ out.push({ val: v, line: row - 1, start: col - 1, end: col - 1 + canon.length })
293
+ }
294
+
295
+ const peg = v.peg
296
+ if (Array.isArray(peg)) {
297
+ for (const c of peg) collectHoverCandidates(c, out, seen)
298
+ }
299
+ else if (null != peg && 'object' === typeof peg) {
300
+ for (const k in peg) collectHoverCandidates(peg[k], out, seen)
301
+ }
302
+ const spreadCj = v.spread?.cj
303
+ if (spreadCj) collectHoverCandidates(spreadCj, out, seen)
304
+ }
305
+
306
+
307
+ function hoverMarkdown(val: any): string {
308
+ let canon = ''
309
+ try { canon = val.canon } catch { canon = '' }
310
+ return '```aontu\n' + canon + '\n```\n\n' + '*' + valKind(val) + '*'
311
+ }
312
+
313
+
314
+ // A short human description of a Val's kind, shown under the hover canon.
315
+ function valKind(val: any): string {
316
+ if (val.isNil) return 'error'
317
+ if (val.isScalarKind) return 'type'
318
+ if (val.isMap) return 'map'
319
+ if (val.isList) return 'list'
320
+ if (val.isRef) return 'reference'
321
+ if (val.isInteger) return 'integer'
322
+ if (val.isNumber) return 'number'
323
+ if (val.isString) return 'string'
324
+ if (val.isBoolean) return 'boolean'
325
+ if (val.isScalar) return 'scalar'
326
+ return val.constructor.name.replace(/Val$/, '').toLowerCase()
327
+ }
328
+
329
+
330
+ // --- Completion -------------------------------------------------------
331
+
332
+ type CompletionItem = {
333
+ label: string
334
+ kind?: number // LSP CompletionItemKind
335
+ detail?: string
336
+ }
337
+
338
+ // LSP CompletionItemKind subset.
339
+ const COMPLETION_FUNCTION = 3
340
+ const COMPLETION_KEYWORD = 14
341
+
342
+ // The twelve built-in functions. Kept in sync with the engine by
343
+ // `lsp.test.ts`, which asserts each is recognised and no others are.
344
+ const BUILTIN_FUNCS = [
345
+ 'close', 'copy', 'hide', 'key', 'lower', 'move',
346
+ 'open', 'path', 'pref', 'super', 'type', 'upper',
347
+ ]
348
+
349
+ // Scalar-kind and literal keywords.
350
+ const KIND_KEYWORDS = ['string', 'number', 'integer', 'boolean']
351
+ const LITERAL_KEYWORDS = ['true', 'false', 'null', 'top']
352
+
353
+
354
+ // Context-free completion: the built-in functions, scalar-kind keywords
355
+ // and literals. Clients filter by the typed prefix.
356
+ function computeCompletions(): CompletionItem[] {
357
+ const out: CompletionItem[] = []
358
+ for (const f of BUILTIN_FUNCS) {
359
+ out.push({ label: f, kind: COMPLETION_FUNCTION, detail: 'Aontu built-in function' })
360
+ }
361
+ for (const k of KIND_KEYWORDS) {
362
+ out.push({ label: k, kind: COMPLETION_KEYWORD, detail: 'scalar kind' })
363
+ }
364
+ for (const k of LITERAL_KEYWORDS) {
365
+ out.push({ label: k, kind: COMPLETION_KEYWORD, detail: 'keyword' })
366
+ }
367
+ return out
368
+ }
369
+
370
+
371
+ // Transport-agnostic LSP message dispatcher. Consumes decoded JSON-RPC
372
+ // messages and returns the messages to send back, tracking open document
373
+ // text and recomputing diagnostics on open/change/close. Not safe for
374
+ // concurrent use; drive it from a single loop (as the stdio server does).
375
+ class LspHandler {
376
+ private docs = new Map<string, string>()
377
+ private shutdownOK = false
378
+ private exited = false
379
+
380
+ // True once an `exit` notification has been received.
381
+ get shouldExit(): boolean { return this.exited }
382
+
383
+ // Process exit code per the LSP spec: 0 if `shutdown` preceded `exit`,
384
+ // else 1.
385
+ get exitCode(): number { return this.shutdownOK ? 0 : 1 }
386
+
387
+ // Current text of an open document, or undefined.
388
+ doc(uri: string): string | undefined { return this.docs.get(uri) }
389
+
390
+ // Process one incoming message, returning zero or more to send.
391
+ handle(msg: Message): OutMessage[] {
392
+ switch (msg.method) {
393
+ case 'initialize':
394
+ return [{ jsonrpc: '2.0', id: msg.id, result: initializeResult() }]
395
+
396
+ case 'initialized':
397
+ return []
398
+
399
+ case 'shutdown':
400
+ this.shutdownOK = true
401
+ return [{ jsonrpc: '2.0', id: msg.id, result: null }]
402
+
403
+ case 'exit':
404
+ this.exited = true
405
+ return []
406
+
407
+ case 'textDocument/didOpen': {
408
+ const td = msg.params?.textDocument
409
+ if (null == td?.uri) return []
410
+ this.docs.set(td.uri, td.text ?? '')
411
+ return [this.publish(td.uri)]
412
+ }
413
+
414
+ case 'textDocument/didChange': {
415
+ const uri = msg.params?.textDocument?.uri
416
+ const changes = msg.params?.contentChanges
417
+ if (null == uri || !Array.isArray(changes) || 0 === changes.length) return []
418
+ // Full document sync: the last change holds the entire new text.
419
+ this.docs.set(uri, changes[changes.length - 1].text ?? '')
420
+ return [this.publish(uri)]
421
+ }
422
+
423
+ case 'textDocument/didClose': {
424
+ const uri = msg.params?.textDocument?.uri
425
+ if (null == uri) return []
426
+ this.docs.delete(uri)
427
+ // Clear diagnostics for the closed document.
428
+ return [publishDiagnosticsMsg(uri, [])]
429
+ }
430
+
431
+ case 'textDocument/hover': {
432
+ const uri = msg.params?.textDocument?.uri
433
+ const pos = msg.params?.position
434
+ const text = null != uri ? this.docs.get(uri) : undefined
435
+ const hover = (null != text && null != pos) ? computeHover(text, pos) : null
436
+ return [{ jsonrpc: '2.0', id: msg.id, result: hover }]
437
+ }
438
+
439
+ case 'textDocument/completion':
440
+ return [{ jsonrpc: '2.0', id: msg.id, result: computeCompletions() }]
441
+
442
+ default:
443
+ // Unknown request (has an id): reply method-not-found. Unknown
444
+ // notification: ignore.
445
+ if (null != msg.id) {
446
+ return [{
447
+ jsonrpc: '2.0',
448
+ id: msg.id,
449
+ error: { code: -32601, message: 'method not found: ' + msg.method },
450
+ }]
451
+ }
452
+ return []
453
+ }
454
+ }
455
+
456
+ private publish(uri: string): OutMessage {
457
+ return publishDiagnosticsMsg(uri, computeDiagnostics(this.docs.get(uri) ?? ''))
458
+ }
459
+ }
460
+
461
+
462
+ export {
463
+ computeDiagnostics,
464
+ computeHover,
465
+ computeCompletions,
466
+ LspHandler,
467
+ LSP_VERSION,
468
+ BUILTIN_FUNCS,
469
+ SEVERITY_ERROR,
470
+ SEVERITY_WARNING,
471
+ SEVERITY_INFORMATION,
472
+ SEVERITY_HINT,
473
+ COMPLETION_FUNCTION,
474
+ COMPLETION_KEYWORD,
475
+ }
476
+
477
+ export type {
478
+ Position,
479
+ Range,
480
+ Diagnostic,
481
+ Message,
482
+ OutMessage,
483
+ Hover,
484
+ MarkupContent,
485
+ CompletionItem,
486
+ }
package/src/type.ts CHANGED
@@ -4,7 +4,7 @@ import * as Fs from 'node:fs'
4
4
 
5
5
  // TODO: refactor these out
6
6
 
7
- import { Resolver } from '@jsonic/multisource'
7
+ import { Resolver } from '@tabnas/multisource'
8
8
 
9
9
  import { AontuContext } from './ctx'
10
10
  import { Val, DONE, SPREAD } from './val/Val'
package/src/unify.ts CHANGED
@@ -157,13 +157,38 @@ const unite = (ctx: AontuContext, a: any, b: any, whence: string) => {
157
157
  // because the branch that set `out = X.unify(Y, ctx)` already
158
158
  // ran that Val's own unify logic.
159
159
  if (!out.done && !unified) {
160
- out = out.unify(top(), te ? ctx.clone({ explain: ec(te, 'ND') }) : ctx)
161
- why += 'T'
160
+ // Once per pass per (Val, path): within a single fixpoint pass
161
+ // nothing external to the subtree changes, so repeating the TOP
162
+ // self-unify — which conjunct folds otherwise trigger once per
163
+ // fold term — is pure re-work, and on large models (hundreds of
164
+ // sibling terms) the repeats trip the MAXCYCLE guard as a false
165
+ // positive. The path is part of the key: a shared Val can
166
+ // resolve path-dependent content differently per location.
167
+ if (undefined !== ctx.cc
168
+ && (out as any)._tcc === ctx.cc && (out as any)._tpi === ctx.pathidx) {
169
+ why += 't'
170
+ }
171
+ else {
172
+ out = out.unify(top(), te ? ctx.clone({ explain: ec(te, 'ND') }) : ctx)
173
+ if (!out.done && undefined !== ctx.cc) {
174
+ ; (out as any)._tcc = ctx.cc
175
+ ; (out as any)._tpi = ctx.pathidx
176
+ }
177
+ why += 'T'
178
+ }
162
179
  }
163
180
  }
164
181
  catch (err: any) {
165
- // TODO: handle unexpected
166
- out = makeNilErr(ctx, 'internal', a, b)
182
+ // This catch-all converts an unexpected exception into an 'internal'
183
+ // Nil so one bad node doesn't crash a whole unify. To avoid fully
184
+ // masking regressions, preserve the original error message (and a
185
+ // RangeError flag — i.e. stack overflow from runaway recursion) on
186
+ // the Nil's details so it surfaces in the formatted error rather
187
+ // than vanishing. See err.ts (descErr) for how details render.
188
+ out = makeNilErr(ctx, 'internal', a, b, undefined, {
189
+ error: String(err?.message ?? err),
190
+ ...(err instanceof RangeError ? { overflow: true } : {}),
191
+ })
167
192
  }
168
193
  }
169
194
 
@@ -229,6 +254,10 @@ class Unify {
229
254
  uctx.err = this.err
230
255
  uctx.explain = this.explain
231
256
 
257
+ // Ref-spread snapshot store (see snapshotRefSpread in MapVal):
258
+ // keyed by ref canon + source site, shared across all passes.
259
+ ; (uctx as any).snapmap = new Map()
260
+
232
261
  const explain = null == ctx?.explain ? undefined : ctx?.explain
233
262
  const te = explain && explainOpen(uctx, explain, 'root', res)
234
263
 
package/src/utility.ts CHANGED
@@ -5,6 +5,14 @@
5
5
  import type { Val } from './type'
6
6
 
7
7
 
8
+ // Default walk() depth limit. High enough that real configs are never
9
+ // silently truncated (the old default of 32 dropped marks on deeply
10
+ // nested refs/funcs → wrong output), while still bounding runaway or
11
+ // accidentally-cyclic walks (walk has no cycle detection). Pass null for
12
+ // truly unbounded.
13
+ const WALK_DEFAULT_MAXDEPTH = 9999
14
+
15
+
8
16
  // Mark value in source is propagated to target (true ratchets).
9
17
  function propagateMarks(source: Val, target: Val): void {
10
18
  // Don't infect top!
@@ -53,7 +61,8 @@ function walk(
53
61
  // After descending into a node.
54
62
  after?: WalkApply,
55
63
 
56
- // Maximum recursive depth, default: 32. Use null for infinite depth.
64
+ // Maximum recursive depth, default: WALK_DEFAULT_MAXDEPTH. Use null for
65
+ // infinite depth.
57
66
  maxdepth?: number | null,
58
67
 
59
68
  // These arguments are used for recursive state.
@@ -63,7 +72,7 @@ function walk(
63
72
  ): Val {
64
73
  let out = null == before ? val : before(key, val, parent, path || [])
65
74
 
66
- maxdepth = null != maxdepth && 0 <= maxdepth ? maxdepth : 32
75
+ maxdepth = null != maxdepth && 0 <= maxdepth ? maxdepth : WALK_DEFAULT_MAXDEPTH
67
76
  if (null != maxdepth && 0 === maxdepth) {
68
77
  return out
69
78
  }
package/src/val/BagVal.ts CHANGED
@@ -65,7 +65,18 @@ abstract class BagVal extends FeatureVal {
65
65
  return undefined
66
66
  }
67
67
 
68
- for (let item of items(this.peg)) {
68
+ // Maps emit their keys alphabetically so the generated output is
69
+ // independent of insertion/unification order (and matches the Go
70
+ // port, whose JSON marshaling also sorts map keys). Lists keep their
71
+ // numeric index order.
72
+ let entries = items(this.peg)
73
+ if (this.isMap) {
74
+ entries = entries
75
+ .slice()
76
+ .sort((a: any, b: any) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
77
+ }
78
+
79
+ for (let item of entries) {
69
80
  const p = item[0]
70
81
  const child = item[1]
71
82
 
@@ -96,7 +107,16 @@ abstract class BagVal extends FeatureVal {
96
107
  || child.isDisjunct
97
108
  || child.isNil
98
109
  ) {
99
- let cval = child.gen(ctx)
110
+ // An optional child is generated in an isolated collect context so an
111
+ // unresolved inner value (a bare type that survived unification, e.g.
112
+ // an absent optional sub-map's `field: string`) is dropped rather than
113
+ // raised: the optional subtree is simply omitted. Without isolation
114
+ // such inner errors pollute the shared ctx.err and make generate()
115
+ // throw even though the key is skipped below. Mirrors the
116
+ // optional-disjunct path above.
117
+ const cctx = optional ? ctx.clone({ err: [], collect: true }) : ctx
118
+
119
+ let cval = child.gen(cctx)
100
120
 
101
121
  if (optional && (undefined === cval || empty(cval))) {
102
122
  continue
@@ -98,27 +98,33 @@ class DisjunctVal extends JunctionVal {
98
98
  // C1-inner: tell `makeNilErr` to return TRIAL_NIL in this scope
99
99
  // instead of allocating per-failure NilVals. Save/restore so
100
100
  // nested DisjunctVal trials (and the outer non-trial code) are
101
- // not affected.
101
+ // not affected. The restore lives in `finally`: if a trial throws,
102
+ // leaving ctx._trialMode=true would collapse every subsequent real
103
+ // error in this ctx to the shared TRIAL_NIL sentinel.
102
104
  ctx._trialMode = true
103
- for (let vI = 0; vI < this.peg.length; vI++) {
104
- const v = this.peg[vI]
105
- const trialErr: any[] = []
106
- ctx.err = trialErr
107
-
108
- oval[vI] = unite(te ? ctx.clone({ explain: ec(te, 'DIST:' + vI) }) : ctx, v, peer, 'dj-peer')
105
+ try {
106
+ for (let vI = 0; vI < this.peg.length; vI++) {
107
+ const v = this.peg[vI]
108
+ const trialErr: any[] = []
109
+ ctx.err = trialErr
110
+
111
+ oval[vI] = unite(te ? ctx.clone({ explain: ec(te, 'DIST:' + vI) }) : ctx, v, peer, 'dj-peer')
112
+
113
+ if (0 < trialErr.length) {
114
+ // C1: failed-trial marker is never user-visible — it just
115
+ // signals "this disjunct member doesn't match" and is
116
+ // filtered out before the result is built. Use the shared
117
+ // sentinel instead of allocating a fresh NilVal per trial.
118
+ oval[vI] = TRIAL_NIL
119
+ }
109
120
 
110
- if (0 < trialErr.length) {
111
- // C1: failed-trial marker is never user-visible — it just
112
- // signals "this disjunct member doesn't match" and is
113
- // filtered out before the result is built. Use the shared
114
- // sentinel instead of allocating a fresh NilVal per trial.
115
- oval[vI] = TRIAL_NIL
121
+ done = done && DONE === oval[vI].dc
116
122
  }
117
-
118
- done = done && DONE === oval[vI].dc
119
123
  }
120
- ctx._trialMode = savedTrialMode
121
- ctx.err = savedErr
124
+ finally {
125
+ ctx._trialMode = savedTrialMode
126
+ ctx.err = savedErr
127
+ }
122
128
 
123
129
  // // // console.log('DISJUNCT-unify-B', this.id, oval.map(v => v.canon))
124
130
 
@@ -257,7 +263,10 @@ class DisjunctVal extends JunctionVal {
257
263
  // TODO: over unifies complex types like maps
258
264
  // ({x:1}|{y:2})&{z:3} should be {"x":1,"z":3}|{"y":2,"z":3} not { x:1, z:3, y:2 }
259
265
  for (let vI = 1; vI < vals.length; vI++) {
260
- let valnext = val.unify(this.peg[vI], ctx)
266
+ // Index `vals`, not `this.peg`: when the pref members are not the
267
+ // leading entries the two arrays differ, and folding against
268
+ // this.peg[vI] would unify the wrong (or a repeated) member.
269
+ let valnext = val.unify(vals[vI], ctx)
261
270
  // // // console.log('DJ-GEN-VALS-NEXT', valnext.canon)
262
271
  val = valnext
263
272
  }
@@ -47,6 +47,7 @@ class IntegerVal extends ScalarVal {
47
47
  }
48
48
  else if (
49
49
  peer.isScalar &&
50
+ peer.kind === this.kind &&
50
51
  peer.peg === this.peg
51
52
  ) {
52
53
  out = this