aontu 0.53.0 → 0.55.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 (197) hide show
  1. package/dist/aontu.d.ts +3 -2
  2. package/dist/aontu.js +36 -7
  3. package/dist/aontu.js.map +1 -1
  4. package/dist/cli.d.ts +2 -1
  5. package/dist/cli.js +500 -8
  6. package/dist/cli.js.map +1 -1
  7. package/dist/ctx.d.ts +5 -0
  8. package/dist/ctx.js +1 -0
  9. package/dist/ctx.js.map +1 -1
  10. package/dist/diff.js.map +1 -1
  11. package/dist/err.js +7 -1
  12. package/dist/err.js.map +1 -1
  13. package/dist/graph.d.ts +2 -5
  14. package/dist/graph.js +83 -46
  15. package/dist/graph.js.map +1 -1
  16. package/dist/hcanon.js +9 -10
  17. package/dist/hcanon.js.map +1 -1
  18. package/dist/hints.js +94 -26
  19. package/dist/hints.js.map +1 -1
  20. package/dist/jsonschema.js +34 -0
  21. package/dist/jsonschema.js.map +1 -1
  22. package/dist/lang.js +593 -191
  23. package/dist/lang.js.map +1 -1
  24. package/dist/lsp.d.ts +1 -1
  25. package/dist/lsp.js +86 -6
  26. package/dist/lsp.js.map +1 -1
  27. package/dist/mcp.js +153 -6
  28. package/dist/mcp.js.map +1 -1
  29. package/dist/mod-tool.js +42 -8
  30. package/dist/mod-tool.js.map +1 -1
  31. package/dist/mod.d.ts +4 -0
  32. package/dist/mod.js +97 -2
  33. package/dist/mod.js.map +1 -1
  34. package/dist/patch.d.ts +5 -0
  35. package/dist/patch.js +25 -25
  36. package/dist/patch.js.map +1 -1
  37. package/dist/provenance.d.ts +1 -0
  38. package/dist/provenance.js +2 -1
  39. package/dist/provenance.js.map +1 -1
  40. package/dist/query.js.map +1 -1
  41. package/dist/reach.d.ts +1 -0
  42. package/dist/reach.js +49 -21
  43. package/dist/reach.js.map +1 -1
  44. package/dist/relation.d.ts +4 -0
  45. package/dist/relation.js +125 -200
  46. package/dist/relation.js.map +1 -1
  47. package/dist/sig.d.ts +25 -0
  48. package/dist/sig.js +277 -0
  49. package/dist/sig.js.map +1 -0
  50. package/dist/sigdecl.d.ts +2 -0
  51. package/dist/sigdecl.js +11 -0
  52. package/dist/sigdecl.js.map +1 -0
  53. package/dist/siggate.d.ts +4 -0
  54. package/dist/siggate.js +90 -0
  55. package/dist/siggate.js.map +1 -0
  56. package/dist/std.js +75 -16
  57. package/dist/std.js.map +1 -1
  58. package/dist/subsume.js +57 -10
  59. package/dist/subsume.js.map +1 -1
  60. package/dist/tsconfig.tsbuildinfo +1 -1
  61. package/dist/unify.d.ts +2 -2
  62. package/dist/unify.js +93 -114
  63. package/dist/unify.js.map +1 -1
  64. package/dist/utility.d.ts +1 -2
  65. package/dist/utility.js +7 -61
  66. package/dist/utility.js.map +1 -1
  67. package/dist/val/AggFuncVal.d.ts +12 -1
  68. package/dist/val/AggFuncVal.js +165 -3
  69. package/dist/val/AggFuncVal.js.map +1 -1
  70. package/dist/val/BagVal.d.ts +2 -0
  71. package/dist/val/BagVal.js +56 -8
  72. package/dist/val/BagVal.js.map +1 -1
  73. package/dist/val/ConstraintVal.js +33 -5
  74. package/dist/val/ConstraintVal.js.map +1 -1
  75. package/dist/val/ContainerKindVal.d.ts +35 -0
  76. package/dist/val/ContainerKindVal.js +99 -0
  77. package/dist/val/ContainerKindVal.js.map +1 -0
  78. package/dist/val/CopyFuncVal.js +0 -7
  79. package/dist/val/CopyFuncVal.js.map +1 -1
  80. package/dist/val/DisjunctVal.d.ts +1 -2
  81. package/dist/val/DisjunctVal.js +146 -39
  82. package/dist/val/DisjunctVal.js.map +1 -1
  83. package/dist/val/ExpectVal.js +37 -2
  84. package/dist/val/ExpectVal.js.map +1 -1
  85. package/dist/val/FuncBaseVal.d.ts +1 -0
  86. package/dist/val/FuncBaseVal.js +19 -8
  87. package/dist/val/FuncBaseVal.js.map +1 -1
  88. package/dist/val/GraphAtomVal.d.ts +39 -0
  89. package/dist/val/GraphAtomVal.js +184 -0
  90. package/dist/val/GraphAtomVal.js.map +1 -0
  91. package/dist/val/JunctionVal.js +22 -5
  92. package/dist/val/JunctionVal.js.map +1 -1
  93. package/dist/val/ListVal.js +27 -34
  94. package/dist/val/ListVal.js.map +1 -1
  95. package/dist/val/MapVal.d.ts +1 -0
  96. package/dist/val/MapVal.js +82 -35
  97. package/dist/val/MapVal.js.map +1 -1
  98. package/dist/val/PathFuncVal.d.ts +2 -2
  99. package/dist/val/PathFuncVal.js +75 -16
  100. package/dist/val/PathFuncVal.js.map +1 -1
  101. package/dist/val/PathVal.d.ts +25 -0
  102. package/dist/val/PathVal.js +150 -0
  103. package/dist/val/PathVal.js.map +1 -0
  104. package/dist/val/PlusOpVal.d.ts +2 -1
  105. package/dist/val/PlusOpVal.js +50 -35
  106. package/dist/val/PlusOpVal.js.map +1 -1
  107. package/dist/val/PrefVal.d.ts +2 -0
  108. package/dist/val/PrefVal.js +159 -32
  109. package/dist/val/PrefVal.js.map +1 -1
  110. package/dist/val/RecurseVal.d.ts +19 -0
  111. package/dist/val/RecurseVal.js +217 -0
  112. package/dist/val/RecurseVal.js.map +1 -0
  113. package/dist/val/RefVal.d.ts +2 -1
  114. package/dist/val/RefVal.js +105 -72
  115. package/dist/val/RefVal.js.map +1 -1
  116. package/dist/val/ReferFuncVal.d.ts +30 -7
  117. package/dist/val/ReferFuncVal.js +395 -94
  118. package/dist/val/ReferFuncVal.js.map +1 -1
  119. package/dist/val/ScalarKindVal.d.ts +4 -2
  120. package/dist/val/ScalarKindVal.js +12 -1
  121. package/dist/val/ScalarKindVal.js.map +1 -1
  122. package/dist/val/SuperFuncVal.d.ts +4 -2
  123. package/dist/val/SuperFuncVal.js +118 -14
  124. package/dist/val/SuperFuncVal.js.map +1 -1
  125. package/dist/val/TopVal.d.ts +1 -1
  126. package/dist/val/Val.d.ts +2 -3
  127. package/dist/val/Val.js +39 -20
  128. package/dist/val/Val.js.map +1 -1
  129. package/dist/val/arith.js +4 -1
  130. package/dist/val/arith.js.map +1 -1
  131. package/dist/vet.d.ts +1 -0
  132. package/dist/vet.js +32 -1
  133. package/dist/vet.js.map +1 -1
  134. package/dist/view.d.ts +90 -0
  135. package/dist/view.js +2168 -0
  136. package/dist/view.js.map +1 -0
  137. package/grammar/aontu.gbnf +18 -10
  138. package/grammar/aontu.lark +15 -10
  139. package/grammar/aontu.tmLanguage.json +184 -0
  140. package/package.json +10 -3
  141. package/skill/grammar-card.md +1 -2
  142. package/src/aontu.ts +37 -7
  143. package/src/cli.ts +553 -8
  144. package/src/ctx.ts +20 -0
  145. package/src/diff.ts +4 -2
  146. package/src/err.ts +8 -1
  147. package/src/graph.ts +125 -75
  148. package/src/hcanon.ts +9 -11
  149. package/src/hints.ts +112 -29
  150. package/src/jsonschema.ts +41 -0
  151. package/src/lang.ts +642 -203
  152. package/src/lsp.ts +75 -6
  153. package/src/mcp.ts +164 -6
  154. package/src/mod-tool.ts +48 -9
  155. package/src/mod.ts +110 -1
  156. package/src/patch.ts +31 -27
  157. package/src/provenance.ts +8 -1
  158. package/src/query.ts +4 -2
  159. package/src/reach.ts +52 -23
  160. package/src/relation.ts +139 -236
  161. package/src/sig.ts +345 -0
  162. package/src/sigdecl.ts +11 -0
  163. package/src/siggate.ts +144 -0
  164. package/src/std.ts +77 -16
  165. package/src/subsume.ts +59 -10
  166. package/src/unify.ts +102 -125
  167. package/src/utility.ts +7 -67
  168. package/src/val/AggFuncVal.ts +231 -4
  169. package/src/val/BagVal.ts +58 -9
  170. package/src/val/ConstraintVal.ts +34 -5
  171. package/src/val/ContainerKindVal.ts +158 -0
  172. package/src/val/CopyFuncVal.ts +0 -7
  173. package/src/val/DisjunctVal.ts +152 -40
  174. package/src/val/ExpectVal.ts +39 -4
  175. package/src/val/FuncBaseVal.ts +21 -8
  176. package/src/val/GraphAtomVal.ts +264 -0
  177. package/src/val/JunctionVal.ts +23 -6
  178. package/src/val/ListVal.ts +30 -37
  179. package/src/val/MapVal.ts +87 -39
  180. package/src/val/PathFuncVal.ts +107 -19
  181. package/src/val/PathVal.ts +221 -0
  182. package/src/val/PlusOpVal.ts +56 -37
  183. package/src/val/PrefVal.ts +186 -38
  184. package/src/val/RecurseVal.ts +285 -0
  185. package/src/val/RefVal.ts +105 -83
  186. package/src/val/ReferFuncVal.ts +445 -100
  187. package/src/val/ScalarKindVal.ts +12 -0
  188. package/src/val/SuperFuncVal.ts +137 -13
  189. package/src/val/TopVal.ts +1 -1
  190. package/src/val/Val.ts +44 -35
  191. package/src/val/arith.ts +4 -1
  192. package/src/vet.ts +41 -4
  193. package/src/view.ts +2882 -0
  194. package/dist/val/IdFuncVal.d.ts +0 -13
  195. package/dist/val/IdFuncVal.js +0 -54
  196. package/dist/val/IdFuncVal.js.map +0 -1
  197. package/src/val/IdFuncVal.ts +0 -91
package/src/lsp.ts CHANGED
@@ -270,6 +270,7 @@ function initializeResult() {
270
270
  textDocumentSync: 1,
271
271
  hoverProvider: true,
272
272
  completionProvider: {},
273
+ signatureHelpProvider: { triggerCharacters: ['(', ','] },
273
274
  },
274
275
  serverInfo: {
275
276
  name: 'aontu-lsp',
@@ -279,6 +280,59 @@ function initializeResult() {
279
280
  }
280
281
 
281
282
 
283
+ // signatureHelp: the declared signature of the ENCLOSING call, served
284
+ // from the registry (docs/design/SIGNATURES.0.md). The enclosing call
285
+ // is found lexically -- scan back from the cursor for the nearest
286
+ // unclosed '(' and read the word before it; commas at that depth
287
+ // count the active parameter, capped at the last slot so a rest tail
288
+ // stays active for every excess argument. Strings are skipped so a
289
+ // paren or comma inside one does not miscount, and the scan stops at
290
+ // the line start, a call being one line in practice.
291
+ function computeSignatureHelp(text: string, pos: any): any {
292
+ // Position to offset, under the full-sync model: lines are exactly
293
+ // the text's newlines.
294
+ const lines = text.split('\n')
295
+ const line = Math.max(0, Math.min(Number(pos.line) || 0, lines.length - 1))
296
+ let offset = 0
297
+ for (let li = 0; li < line; li++) {
298
+ offset += lines[li].length + 1
299
+ }
300
+ offset += Math.max(0, Math.min(Number(pos.character) || 0, lines[line].length))
301
+ let depth = 0
302
+ let commas = 0
303
+ let open = -1
304
+ for (let i = offset - 1; 0 <= i; i--) {
305
+ const c = text[i]
306
+ if ('"' === c || "'" === c) {
307
+ for (i--; 0 <= i && text[i] !== c; i--) { }
308
+ continue
309
+ }
310
+ if (')' === c) { depth++ }
311
+ else if ('(' === c) {
312
+ if (0 === depth) { open = i; break }
313
+ depth--
314
+ }
315
+ else if (',' === c && 0 === depth) { commas++ }
316
+ else if ('\n' === c && 0 === depth) { break }
317
+ }
318
+ if (0 > open) { return null }
319
+ let start = open
320
+ while (0 < start && /[a-zA-Z0-9_]/.test(text[start - 1])) { start-- }
321
+ const name = text.slice(start, open)
322
+ const sig = funcSig[name]
323
+ if (undefined === sig) { return null }
324
+ const last = sig.args.length - 1
325
+ return {
326
+ signatures: [{
327
+ label: renderSig(sig),
328
+ parameters: sig.args.map((a) => ({ label: renderSigArg(a) })),
329
+ }],
330
+ activeSignature: 0,
331
+ activeParameter: Math.min(commas, 0 <= last ? last : 0),
332
+ }
333
+ }
334
+
335
+
282
336
  function publishDiagnosticsMsg(uri: string, diagnostics: Diagnostic[]): OutMessage {
283
337
  return {
284
338
  jsonrpc: '2.0',
@@ -457,23 +511,26 @@ type CompletionItem = {
457
511
  detail?: string
458
512
  }
459
513
 
514
+ import { funcSig, renderSig, renderSigArg } from './sig'
515
+
460
516
  // LSP CompletionItemKind subset.
461
517
  const COMPLETION_FUNCTION = 3
462
518
  const COMPLETION_KEYWORD = 14
463
519
 
464
- // The twenty-eight built-in functions. Kept in sync with the engine by
520
+ // The built-in functions. Kept in sync with the engine by
465
521
  // `lsp.test.ts`, which asserts each is recognised and no others are.
466
522
  // The Go port derives its list from the engine's own name set
467
523
  // (`BuiltinFuncNames`, go/func.go), which is why a name added there
468
524
  // and forgotten here diverges silently — as `id` and `refer` did
469
525
  // between G4 phases 1/2 and G8 phase 1.
470
526
  const BUILTIN_FUNCS = [
471
- 'above', 'add', 'below', 'close', 'copy', 'deprecate', 'div', 'each',
527
+ 'above', 'acyclic', 'add', 'below', 'close', 'copy', 'deprecate', 'div',
528
+ 'each',
472
529
  'filter', 'greatest',
473
- 'hide', 'id', 'key', 'least', 'length', 'lower',
474
- 'match', 'max', 'min', 'mod', 'move', 'mul', 'must', 'neq', 'open',
530
+ 'hide', 'inverse', 'join', 'key', 'least', 'length', 'list', 'lower',
531
+ 'map', 'match', 'max', 'min', 'mod', 'move', 'mul', 'must', 'neq', 'open',
475
532
  'pack', 'path', 'pick',
476
- 'pref', 're', 'refer', 'rem', 'sub', 'sum', 'super', 'type', 'unique',
533
+ 'pref', 're', 'refer', 'rel', 'rem', 'sub', 'sum', 'super', 'type', 'unique',
477
534
  'upper',
478
535
  ]
479
536
 
@@ -493,7 +550,10 @@ const LITERAL_KEYWORDS = ['_', 'true', 'false', 'null', 'top']
493
550
  function computeCompletions(): CompletionItem[] {
494
551
  const out: CompletionItem[] = []
495
552
  for (const f of BUILTIN_FUNCS) {
496
- out.push({ label: f, kind: COMPLETION_FUNCTION, detail: 'Aontu built-in function' })
553
+ // The detail is the rendered SIGNATURE (docs/design/SIGNATURES.0.md)
554
+ // -- the same renderer the hints and the docs table use, so the
555
+ // completion list cannot drift from the declaration.
556
+ out.push({ label: f, kind: COMPLETION_FUNCTION, detail: renderSig(funcSig[f]) })
497
557
  }
498
558
  for (const k of KIND_KEYWORDS) {
499
559
  out.push({ label: k, kind: COMPLETION_KEYWORD, detail: 'scalar kind' })
@@ -695,6 +755,15 @@ class LspHandler {
695
755
  case 'textDocument/completion':
696
756
  return [{ jsonrpc: '2.0', id: msg.id, result: computeCompletions() }]
697
757
 
758
+ case 'textDocument/signatureHelp': {
759
+ const uri = msg.params?.textDocument?.uri
760
+ const pos = msg.params?.position
761
+ const text = null != uri ? this.docs.get(uri) : undefined
762
+ const help = (null != text && null != pos)
763
+ ? computeSignatureHelp(text, pos) : null
764
+ return [{ jsonrpc: '2.0', id: msg.id, result: help }]
765
+ }
766
+
698
767
  default:
699
768
  // Unknown request (has an id): reply method-not-found. Unknown
700
769
  // notification: ignore.
package/src/mcp.ts CHANGED
@@ -48,6 +48,7 @@ import { trimCheck } from './trim'
48
48
  import { jsonSchema } from './jsonschema'
49
49
  import { relationCheck } from './relation'
50
50
  import { reachCheck } from './reach'
51
+ import { view } from './view'
51
52
  import { patch } from './patch'
52
53
 
53
54
 
@@ -114,7 +115,9 @@ export type ToolDef = {
114
115
  // applied by each tool for itself. That is deliberate: four of the six
115
116
  // original tools once called the library with no profile at all, so a
116
117
  // served `@"x.js"` was require()d in the server process, while the
117
- // module header claimed confinement. A tool that must remember to
118
+ // module header claimed confinement. (That particular include is
119
+ // refused outright now -- ADR-012 -- but a served document could still
120
+ // read every file the server can.) A tool that must remember to
118
121
  // confine itself is a tool that eventually forgets, and the forgetting
119
122
  // is silent. With the profile arriving as an argument, a tool cannot
120
123
  // run unconfined without visibly discarding it.
@@ -408,7 +411,7 @@ const TOOLS: ToolDef[] = [
408
411
  name: 'relations',
409
412
  description:
410
413
  'Check the declared relations of a finished model: acyclicity ' +
411
- 'and inverse consistency over the entity edge set. Returns ' +
414
+ 'and inverse consistency over the link edge set. Returns ' +
412
415
  'verdict (pass | fail | error) and relation findings.',
413
416
  properties: {
414
417
  source: { type: 'string', description: 'The document' },
@@ -428,16 +431,16 @@ const TOOLS: ToolDef[] = [
428
431
  {
429
432
  name: 'reaches',
430
433
  description:
431
- 'Ask whether one entity reaches another over the entity graph, ' +
434
+ 'Ask whether one node reaches another over the link graph, ' +
432
435
  'at any remove — the closure question `relations` cannot ask one ' +
433
436
  'edge at a time (blast radius, containment). Returns verdict ' +
434
437
  '(reaches | unreachable | error) and, when it reaches, a shortest ' +
435
- 'path. Transitive, not reflexive: an entity reaches itself only ' +
438
+ 'path. Transitive, not reflexive: a node reaches itself only ' +
436
439
  'through a cycle.',
437
440
  properties: {
438
441
  source: { type: 'string', description: 'The document' },
439
- from: { type: 'string', description: 'The entity to start at' },
440
- to: { type: 'string', description: 'The entity to look for' },
442
+ from: { type: 'string', description: 'The node path to start at' },
443
+ to: { type: 'string', description: 'The node path to look for' },
441
444
  relation: {
442
445
  type: 'string',
443
446
  description: 'Follow only edges under this relation (optional)',
@@ -452,6 +455,161 @@ const TOOLS: ToolDef[] = [
452
455
  relation: null == a.relation ? undefined : str(a.relation),
453
456
  }),
454
457
  },
458
+ {
459
+ name: 'view',
460
+ description:
461
+ 'Draw a figure of the document as deterministic text. Kinds: ' +
462
+ 'tree (the dependency tree of a relation), matrix (the ' +
463
+ 'dependency matrix, canon or partition order, --closure), graph ' +
464
+ '(node-link, as mermaid, dot or er), layer (the architecture ' +
465
+ 'layers as stacked bands, groupBy naming the layer field), sets ' +
466
+ '(the set-intersection ' +
467
+ 'panel over a set family), layers (which document contributed ' +
468
+ 'which path), ladder (the meet ladder at a path). Returns ' +
469
+ 'verdict (rendered | lossy | error), kind, the text, and the ' +
470
+ 'loss report. The poset kind compares several files and is CLI ' +
471
+ 'only.',
472
+ properties: {
473
+ source: { type: 'string', description: 'The document' },
474
+ kind: {
475
+ type: 'string',
476
+ description:
477
+ 'The figure to draw: tree (the default), matrix, graph, layer, ' +
478
+ 'sets, layers or ladder',
479
+ },
480
+ as: {
481
+ type: 'string',
482
+ description:
483
+ 'The target grammar: text, mermaid, dot, er or svg, per kind ' +
484
+ '(optional; the kind\'s default otherwise)',
485
+ },
486
+ at: {
487
+ type: 'string',
488
+ description:
489
+ 'Restrict the figure to nodes under this path; the path the ' +
490
+ 'ladder draws (optional)',
491
+ },
492
+ relation: {
493
+ type: 'string',
494
+ description:
495
+ 'Draw the tree or matrix over this relation only; keep this ' +
496
+ 'predicate in the graph (optional)',
497
+ },
498
+ root: {
499
+ type: 'array',
500
+ items: { type: 'string' },
501
+ description:
502
+ 'Draw only the subtrees under these node paths (optional)',
503
+ },
504
+ order: {
505
+ type: 'string',
506
+ description: 'matrix: canon (the default) or partition (optional)',
507
+ },
508
+ closure: {
509
+ type: 'boolean',
510
+ description: 'matrix: mark transitively reachable cells (optional)',
511
+ },
512
+ groupBy: {
513
+ type: 'string',
514
+ description:
515
+ 'graph: one subgraph per value of this field (optional); ' +
516
+ 'layer: one band per value (required)',
517
+ },
518
+ label: {
519
+ type: 'string',
520
+ description: 'graph: label each node with this field (optional)',
521
+ },
522
+ edges: {
523
+ type: 'string',
524
+ description:
525
+ 'layer: which of the relation\'s edges to draw over the bands -- ' +
526
+ 'upward (the violations; the default for text and svg), all ' +
527
+ '(mermaid\'s default) or none (optional)',
528
+ },
529
+ layers: {
530
+ type: 'array',
531
+ items: { type: 'string' },
532
+ description: 'layer: the bands in this order, top first (optional)',
533
+ },
534
+ sets: {
535
+ type: 'string',
536
+ description: 'sets: the map whose keys are the sets',
537
+ },
538
+ member: {
539
+ type: 'string',
540
+ description: 'sets: the field holding each set\'s members',
541
+ },
542
+ universe: {
543
+ type: 'string',
544
+ description: 'sets: the full element domain (optional)',
545
+ },
546
+ depth: {
547
+ type: 'integer',
548
+ description: 'doc: how many levels of key to draw (default 3)',
549
+ },
550
+ maxRows: {
551
+ type: 'integer',
552
+ description: 'Refuse a figure above this many rows (default 60)',
553
+ },
554
+ style: {
555
+ type: 'string',
556
+ description:
557
+ 'How the figure carries the meaning of its marks: none, ansi ' +
558
+ '(SGR escapes, text only) or css (classes and the embedded ' +
559
+ 'stylesheet, svg only). Absent means the profile\'s own ' +
560
+ 'default -- svg keeps its stylesheet, everything else has no ' +
561
+ 'mechanism. There is no auto here: resolving it needs a ' +
562
+ 'terminal, which a server does not have (optional)',
563
+ },
564
+ },
565
+ required: ['source'],
566
+ docs: ['source'],
567
+ check: (a) => {
568
+ const kinds = ['doc', 'tree', 'matrix', 'graph', 'layer', 'sets',
569
+ 'layers', 'ladder']
570
+ if (null != a.kind && !kinds.includes(a.kind)) {
571
+ return `kind must be one of ${kinds.join(', ')}, not ${JSON.stringify(a.kind)}`
572
+ }
573
+ const profiles = ['text', 'mermaid', 'dot', 'er', 'svg']
574
+ if (null != a.as && !profiles.includes(a.as)) {
575
+ return `as must be one of ${profiles.join(', ')}, not ${JSON.stringify(a.as)}`
576
+ }
577
+ const edges = ['upward', 'all', 'none']
578
+ if (null != a.edges && !edges.includes(a.edges)) {
579
+ return `edges must be one of ${edges.join(', ')}, not ${JSON.stringify(a.edges)}`
580
+ }
581
+ const styles = ['none', 'ansi', 'css']
582
+ if (null != a.style && !styles.includes(a.style)) {
583
+ return `style must be one of ${styles.join(', ')}, not ${JSON.stringify(a.style)}`
584
+ }
585
+ return undefined
586
+ },
587
+ refuse: (a, finding) =>
588
+ ({ verdict: 'error', kind: a.kind ?? 'tree', loss: [], errors: [finding] }),
589
+ run: (a, trust, paths) =>
590
+ view(str(a.source), {
591
+ kind: null == a.kind ? 'tree' : a.kind,
592
+ as: null == a.as ? undefined : a.as,
593
+ at: null == a.at ? undefined : str(a.at),
594
+ path: paths.source,
595
+ trust,
596
+ relation: null == a.relation ? undefined : str(a.relation),
597
+ relations: null == a.relation ? undefined : [str(a.relation)],
598
+ roots: Array.isArray(a.root) ? a.root.map(str) : undefined,
599
+ order: null == a.order ? undefined : a.order,
600
+ closure: true === a.closure,
601
+ groupBy: null == a.groupBy ? undefined : str(a.groupBy),
602
+ label: null == a.label ? undefined : str(a.label),
603
+ layers: Array.isArray(a.layers) ? a.layers.map(str) : undefined,
604
+ edges: null == a.edges ? undefined : a.edges,
605
+ sets: null == a.sets ? undefined : str(a.sets),
606
+ member: null == a.member ? undefined : str(a.member),
607
+ universe: null == a.universe ? undefined : str(a.universe),
608
+ depth: 'number' === typeof a.depth ? a.depth : undefined,
609
+ maxRows: 'number' === typeof a.maxRows ? a.maxRows : undefined,
610
+ style: null == a.style ? undefined : a.style,
611
+ }),
612
+ },
455
613
  {
456
614
  name: 'hash',
457
615
  description:
package/src/mod-tool.ts CHANGED
@@ -24,7 +24,9 @@ import {
24
24
  } from 'node:fs'
25
25
  import { join as pathJoin, dirname as pathDirname } from 'node:path'
26
26
 
27
- import { parseModuleRef, moduleDir, lockJson } from './mod'
27
+ import {
28
+ parseModuleRef, validateModulePath, moduleDir, lockJson,
29
+ } from './mod'
28
30
  import { subsume } from './subsume'
29
31
  import type { ModuleRef } from './mod'
30
32
 
@@ -162,6 +164,30 @@ export function versionCompare(a: string, b: string): number {
162
164
  }
163
165
 
164
166
 
167
+ // A dependency or lockfile key as a module ref this tooling may ACT
168
+ // on, or undefined.
169
+ //
170
+ // Two ways to be unusable, one answer. A key that is not module-shaped
171
+ // names nothing the resolver can find; a key that is shaped but whose
172
+ // path cannot legally be a directory (`..` in it, a reserved device
173
+ // name) must not be turned into one, which is the whole of the
174
+ // traversal fix on this side. Both land in the caller's `missing`
175
+ // bucket, because from the report's point of view they are the same
176
+ // fact: the lockfile names something that cannot be resolved here.
177
+ //
178
+ // A STALE LOCKFILE IS THE REASON THIS EXISTS AT ALL. `resolveModule`
179
+ // gates the evaluator, but `tidy`, `verify` and `vendor` read a
180
+ // lockfile straight off disk -- one that may have been committed
181
+ // before the gate existed -- so the gate has to be here too.
182
+ function usableRef(mod: string): ModuleRef | undefined {
183
+ const ref = parseModuleRef(mod)
184
+ if (undefined === ref || undefined !== validateModulePath(ref.path)) {
185
+ return undefined
186
+ }
187
+ return ref
188
+ }
189
+
190
+
165
191
  // The directory a module is in, in the local stores: the project's
166
192
  // vendor tree first, then the cache under the hash the lockfile pins.
167
193
  function storeDir(
@@ -245,11 +271,11 @@ export function modTidy(root: string, options: ModToolOptions): ModTidyReport {
245
271
  }
246
272
  selected[mod] = want
247
273
 
248
- const ref = parseModuleRef(mod)
274
+ const ref = usableRef(mod)
249
275
  if (undefined === ref) {
250
- // A dependency key that is not a module path names nothing this
251
- // resolver can find, which is the same answer as a module that
252
- // is not there.
276
+ // A dependency key this tooling cannot act on names nothing
277
+ // this resolver can find, which is the same answer as a module
278
+ // that is not there (see usableRef).
253
279
  missing.push(mod)
254
280
  continue
255
281
  }
@@ -278,7 +304,7 @@ export function modTidy(root: string, options: ModToolOptions): ModTidyReport {
278
304
  if (missing.includes(mod)) {
279
305
  continue
280
306
  }
281
- const ref = parseModuleRef(mod) as ModuleRef
307
+ const ref = usableRef(mod) as ModuleRef
282
308
  const dir = storeDir(root, ref, previous[mod]?.canon ?? '', options) as string
283
309
  const main = pathJoin(dir, mainOf(dir, options))
284
310
  // RECOMPUTED, never carried over: the pin is what the module in
@@ -374,7 +400,7 @@ export function modVerify(root: string, options: ModToolOptions):
374
400
  .filter((mod) => null == locked[mod]).sort()
375
401
 
376
402
  for (const mod of Object.keys(locked).sort()) {
377
- const ref = parseModuleRef(mod)
403
+ const ref = usableRef(mod)
378
404
  if (undefined === ref) {
379
405
  missing.push(mod)
380
406
  continue
@@ -422,8 +448,10 @@ export function modVendor(root: string, options: ModToolOptions):
422
448
  const vendored: string[] = []
423
449
  const missing: string[] = []
424
450
 
451
+ const vendorRoot = pathJoin(root, 'aon_vendor')
452
+
425
453
  for (const mod of Object.keys(locked).sort()) {
426
- const ref = parseModuleRef(mod)
454
+ const ref = usableRef(mod)
427
455
  if (undefined === ref) {
428
456
  missing.push(mod)
429
457
  continue
@@ -435,7 +463,18 @@ export function modVendor(root: string, options: ModToolOptions):
435
463
  continue
436
464
  }
437
465
 
438
- const to = moduleDir(pathJoin(root, 'aon_vendor'), ref)
466
+ // WHY THERE IS NO CONTAINMENT CHECK ON `to`, at the one write site
467
+ // that copied a tree outside the project: `usableRef` above is the
468
+ // gate, and after it a store path CANNOT escape. Every element is
469
+ // non-empty and neither begins nor ends with `.`, so none is `.`
470
+ // or `..`; MODULE_RE's element class admits no `/`, no `\` and no
471
+ // leading slash, so no element can re-root the join. A second
472
+ // lexical check here would be unreachable code, which ADR-002 asks
473
+ // to be deleted rather than excluded -- so the invariant is pinned
474
+ // by a test that drives the escape through this verb instead.
475
+ // ANY NEW CALLER of moduleDir must go through usableRef too;
476
+ // `mod get` is the next one.
477
+ const to = moduleDir(vendorRoot, ref)
439
478
  if (from !== to) {
440
479
  copyTree(from, to)
441
480
  }
package/src/mod.ts CHANGED
@@ -73,9 +73,94 @@ export function parseModuleRef(spec: string): ModuleRef | undefined {
73
73
  }
74
74
 
75
75
 
76
+ // SHAPE IS NOT VALIDITY, and the gap between them was a hole. MODULE_RE
77
+ // answers "does this string route to the module resolver" -- a
78
+ // ROUTING predicate, and it must stay one, because anything it rejects
79
+ // falls through to the file leg and a stricter pattern would silently
80
+ // re-route documents that work today. But its element class
81
+ // `[A-Za-z0-9._-]` admits `..`, and `moduleDir` joins elements with
82
+ // pathJoin, which CLEANS `..` rather than refusing it:
83
+ //
84
+ // moduleDir('/store/aon_vendor', 'corp.example/../../etc/passwd@1')
85
+ // -> /store/etc/passwd@1
86
+ //
87
+ // `mod vendor` then copied a tree THERE, outside the project entirely,
88
+ // and reported `verdict: ok`. So validity is a separate question asked
89
+ // separately, after the shape matched, and asked at every site that
90
+ // turns a module path into a directory.
91
+ //
92
+ // The rules are Go's (golang.org/x/mod/module.CheckPath), for the
93
+ // reason Go has them: a module path becomes a real directory on every
94
+ // platform the toolchain runs on, so it must be a legal one everywhere.
95
+ export const MODULE_MAX_PATH = 512
96
+ export const MODULE_MAX_ELEMS = 32
97
+
98
+
99
+ // Windows refuses these as file names whatever the extension, so a
100
+ // module path containing one cannot be materialised there at all. The
101
+ // check is on the element up to its first dot, which is where Windows
102
+ // stops looking too.
103
+ const RESERVED_ELEMS = new Set([
104
+ 'con', 'prn', 'aux', 'nul',
105
+ 'com1', 'com2', 'com3', 'com4', 'com5', 'com6', 'com7', 'com8', 'com9',
106
+ 'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7', 'lpt8', 'lpt9',
107
+ ])
108
+
109
+
110
+ // Why a module path may not be used as a directory, or undefined when
111
+ // it may. The reason is user-facing: it goes in the refusal, because a
112
+ // path refused without saying which rule it broke is a puzzle.
113
+ export function validateModulePath(path: string): string | undefined {
114
+ if (MODULE_MAX_PATH < path.length) {
115
+ return 'longer than ' + MODULE_MAX_PATH + ' characters'
116
+ }
117
+
118
+ const elems = path.split('/')
119
+ if (MODULE_MAX_ELEMS < elems.length) {
120
+ return 'more than ' + MODULE_MAX_ELEMS + ' elements'
121
+ }
122
+
123
+ for (const elem of elems) {
124
+ if ('' === elem) {
125
+ return 'an element is empty'
126
+ }
127
+ // This one rule kills `.` and `..` -- the traversal -- along with
128
+ // `.hidden` and `trailing.`, exactly as Go's does. Stating it as
129
+ // the rule rather than as "no `..`" is deliberate: a check that
130
+ // named the two dangerous spellings would miss the next one.
131
+ if (elem.startsWith('.') || elem.endsWith('.')) {
132
+ return 'an element begins or ends with "."'
133
+ }
134
+ if (RESERVED_ELEMS.has(elem.split('.')[0].toLowerCase())) {
135
+ return 'an element is a reserved device name'
136
+ }
137
+ }
138
+
139
+ return undefined
140
+ }
141
+
142
+
143
+ // An element as it is spelled ON DISK. Uppercase is escaped to
144
+ // `!`+lowercase, Go's rule (go.dev/ref/mod, module proxy protocol) and
145
+ // for Go's reason: `github.com/Alice/Widgets` and
146
+ // `github.com/alice/widgets` are two module identities and, on macOS
147
+ // and Windows, ONE directory -- so without this the second module
148
+ // fetched silently clobbers the first, and an unpinned import resolves
149
+ // to whichever won.
150
+ //
151
+ // The WRITTEN path stays the identity; only the directory is escaped.
152
+ function escapeElem(elem: string): string {
153
+ return elem.replace(/[A-Z]/g, (c) => '!' + c.toLowerCase())
154
+ }
155
+
156
+
76
157
  // The directory a module's files live in, under a store root.
158
+ //
159
+ // Callers must have validated the path (validateModulePath); this
160
+ // function cannot refuse, because it answers a location rather than a
161
+ // question, and every caller has a refusal shape of its own.
77
162
  export function moduleDir(store: string, ref: ModuleRef): string {
78
- return pathJoin(store, ...ref.path.split('/')) + '@' + ref.major
163
+ return pathJoin(store, ...ref.path.split('/').map(escapeElem)) + '@' + ref.major
79
164
  }
80
165
 
81
166
 
@@ -248,6 +333,19 @@ export type ModuleFound = {
248
333
  }
249
334
 
250
335
 
336
+ // EVERY code `resolveModule` can refuse with. The list lives HERE,
337
+ // beside the refusals themselves, because the parse layer has to
338
+ // recognise them to turn the throw into a parse-stage nil
339
+ // (ts/src/lang.ts) -- and when that list was written out longhand
340
+ // there, adding a fourth code left it unhandled and the refusal
341
+ // surfaced as `unexpected error` instead of the message it carries.
342
+ // The Go port has no such list (recordModErr takes any code), which is
343
+ // why only this side could drift.
344
+ export const MODULE_REFUSAL_CODES: ReadonlySet<string> = new Set([
345
+ 'module_path', 'module_missing', 'module_integrity', 'module_depth',
346
+ ])
347
+
348
+
251
349
  // A refusal that carries its code to the parse layer, exactly as a
252
350
  // denied include does (makeModelResolver's `deny`): the resolver
253
351
  // THROWS, so a bare-member module import cannot vanish in the merge and
@@ -266,6 +364,17 @@ export function resolveModule(
266
364
  fs: ModuleFs,
267
365
  options: ModuleOptions,
268
366
  ): ModuleFound {
367
+ // THE PATH IS CHECKED BEFORE ANYTHING IS BUILT FROM IT. This is
368
+ // first because it is a question about the REQUEST, not about the
369
+ // state of the machine: a path that cannot legally be a directory is
370
+ // refused identically whether or not the module is present, and
371
+ // whether or not the depth bound is near.
372
+ const badpath = validateModulePath(ref.path)
373
+ if (undefined !== badpath) {
374
+ refuse('module_path',
375
+ 'module path: ' + ref.path + '@' + ref.major + ' (' + badpath + ')')
376
+ }
377
+
269
378
  if (MODULE_MAX_DEPTH <= (options.depth ?? 0)) {
270
379
  refuse('module_depth',
271
380
  'module depth: ' + ref.path + '@' + ref.major +
package/src/patch.ts CHANGED
@@ -333,34 +333,36 @@ function editableLiteral(
333
333
  }
334
334
  }
335
335
 
336
- const one = literals[0]
336
+ return verifiedSite(overlaySrc, path, literals[0])
337
+ }
337
338
 
338
- // THE SPAN MUST CHECK OUT, and this is one condition rather than two.
339
- //
340
- // A first draft tested "no extent" separately from "the text
341
- // disagrees", which read as two guards and was really one question
342
- // asked twice — with the second half unreachable, since denying
343
- // includes means the site comes from evaluating THIS TEXT with
344
- // nothing loaded, so its coordinates describe this text by
345
- // construction. Merged, the question is reachable through the case
346
- // that has no extent at all (`x: hello |> upper` synthesises a call
347
- // the parser never sited), so the check is exercised rather than
348
- // argued for — and `spanHolds` is exported and tested against sites
349
- // the engine would never produce, which is the only way to reach the
350
- // half that remains theoretical.
351
- //
352
- // It is load-bearing either way: a contribution with no `src` would
353
- // otherwise splice ZERO characters, INSERTING the new value into the
354
- // middle of a line instead of replacing anything.
339
+
340
+ // THE SPAN MUST CHECK OUT before anything splices. The refusal arm is
341
+ // unreachable through `patch` since ADR-018: the pipe (`x: hello |>
342
+ // upper`) was the one spelling that synthesised a contribution the
343
+ // parser never sited, and denying includes means every WRITTEN
344
+ // contribution's coordinates describe this text by construction. The
345
+ // verification is kept rather than deleted — splicing without it would
346
+ // corrupt the file (a contribution with no `src` would splice ZERO
347
+ // characters, INSERTING the new value into the middle of a line) — and
348
+ // this last step is its own exported seam so the refusal can be tested
349
+ // directly, against conjuncts the engine would never produce, on the
350
+ // same footing as `spanHolds` itself.
351
+ export function verifiedSite(
352
+ overlaySrc: string,
353
+ path: string,
354
+ one: WhyConjunct,
355
+ ): { site: PatchReplacement | undefined, finding: VetFinding | undefined } {
355
356
  if (!spanHolds(overlaySrc, one.site, one.src)) {
356
- return {
357
- site: undefined,
358
- finding: notEditable('patch_span_mismatch', path,
359
- 'the overlay does not hold ' + JSON.stringify(one.src) + ' at ' +
360
- one.site.row + ':' + one.site.col + ' (len ' + one.site.len +
361
- '), so the span cannot be verified before writing',
362
- [one]),
363
- }
357
+ const finding = notEditable('patch_span_mismatch', path,
358
+ 'the overlay does not hold ' + JSON.stringify(one.src) + ' at ' +
359
+ one.site.row + ':' + one.site.col + ' (len ' + one.site.len +
360
+ '), so the span cannot be verified before writing',
361
+ [one])
362
+ // The one internal-class refusal: a recorded span failing to check
363
+ // out is the engine's fault, never the document's.
364
+ finding.class = 'internal'
365
+ return { site: undefined, finding }
364
366
  }
365
367
 
366
368
  // DOES THE SPAN MEAN THE WHOLE CONTRIBUTION?
@@ -479,7 +481,9 @@ function notEditable(
479
481
  ): VetFinding {
480
482
  return {
481
483
  code,
482
- class: 'patch_span_mismatch' === code ? 'internal' : 'reference',
484
+ // Always `reference`; the one internal-class refusal
485
+ // (patch_span_mismatch) overrides at its call site.
486
+ class: 'reference',
483
487
  severity: 'warning',
484
488
  path,
485
489
  // No separate `note`: the renderer prints both, and a note that