aontu 0.52.1 → 0.54.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 (310) hide show
  1. package/README.md +88 -0
  2. package/bin/aontu-mcp.js +4 -0
  3. package/dist/agentsmd.d.ts +16 -0
  4. package/dist/agentsmd.js +107 -0
  5. package/dist/agentsmd.js.map +1 -0
  6. package/dist/aontu.d.ts +15 -3
  7. package/dist/aontu.js +126 -5
  8. package/dist/aontu.js.map +1 -1
  9. package/dist/cli.d.ts +45 -1
  10. package/dist/cli.js +2845 -69
  11. package/dist/cli.js.map +1 -1
  12. package/dist/ctx.d.ts +18 -0
  13. package/dist/ctx.js +44 -0
  14. package/dist/ctx.js.map +1 -1
  15. package/dist/diff.d.ts +22 -0
  16. package/dist/diff.js +141 -0
  17. package/dist/diff.js.map +1 -0
  18. package/dist/err.d.ts +3 -1
  19. package/dist/err.js +45 -8
  20. package/dist/err.js.map +1 -1
  21. package/dist/graph.d.ts +13 -0
  22. package/dist/graph.js +110 -0
  23. package/dist/graph.js.map +1 -0
  24. package/dist/hcanon.d.ts +3 -0
  25. package/dist/hcanon.js +145 -0
  26. package/dist/hcanon.js.map +1 -0
  27. package/dist/hints.js +235 -8
  28. package/dist/hints.js.map +1 -1
  29. package/dist/jsonschema.d.ts +20 -0
  30. package/dist/jsonschema.js +425 -0
  31. package/dist/jsonschema.js.map +1 -0
  32. package/dist/lang.js +955 -110
  33. package/dist/lang.js.map +1 -1
  34. package/dist/lsp.d.ts +9 -2
  35. package/dist/lsp.js +343 -48
  36. package/dist/lsp.js.map +1 -1
  37. package/dist/mcp-server.d.ts +20 -0
  38. package/dist/mcp-server.js +147 -0
  39. package/dist/mcp-server.js.map +1 -0
  40. package/dist/mcp.d.ts +42 -0
  41. package/dist/mcp.js +941 -0
  42. package/dist/mcp.js.map +1 -0
  43. package/dist/mod-tool.d.ts +58 -0
  44. package/dist/mod-tool.js +532 -0
  45. package/dist/mod-tool.js.map +1 -0
  46. package/dist/mod.d.ts +35 -0
  47. package/dist/mod.js +345 -0
  48. package/dist/mod.js.map +1 -0
  49. package/dist/patch.d.ts +49 -0
  50. package/dist/patch.js +506 -0
  51. package/dist/patch.js.map +1 -0
  52. package/dist/provenance.d.ts +41 -0
  53. package/dist/provenance.js +336 -0
  54. package/dist/provenance.js.map +1 -0
  55. package/dist/query.d.ts +27 -0
  56. package/dist/query.js +294 -0
  57. package/dist/query.js.map +1 -0
  58. package/dist/reach.d.ts +15 -0
  59. package/dist/reach.js +168 -0
  60. package/dist/reach.js.map +1 -0
  61. package/dist/relation.d.ts +23 -0
  62. package/dist/relation.js +230 -0
  63. package/dist/relation.js.map +1 -0
  64. package/dist/report-sarif.d.ts +14 -0
  65. package/dist/report-sarif.js +102 -0
  66. package/dist/report-sarif.js.map +1 -0
  67. package/dist/sig.d.ts +25 -0
  68. package/dist/sig.js +277 -0
  69. package/dist/sig.js.map +1 -0
  70. package/dist/sigdecl.d.ts +2 -0
  71. package/dist/sigdecl.js +11 -0
  72. package/dist/sigdecl.js.map +1 -0
  73. package/dist/siggate.d.ts +4 -0
  74. package/dist/siggate.js +90 -0
  75. package/dist/siggate.js.map +1 -0
  76. package/dist/site.d.ts +4 -0
  77. package/dist/site.js +31 -0
  78. package/dist/site.js.map +1 -1
  79. package/dist/std.d.ts +1 -0
  80. package/dist/std.js +129 -0
  81. package/dist/std.js.map +1 -0
  82. package/dist/subsume.d.ts +39 -0
  83. package/dist/subsume.js +573 -0
  84. package/dist/subsume.js.map +1 -0
  85. package/dist/trim.d.ts +19 -0
  86. package/dist/trim.js +155 -0
  87. package/dist/trim.js.map +1 -0
  88. package/dist/tsconfig.tsbuildinfo +1 -1
  89. package/dist/type.d.ts +17 -1
  90. package/dist/type.js.map +1 -1
  91. package/dist/unify.d.ts +2 -1
  92. package/dist/unify.js +234 -38
  93. package/dist/unify.js.map +1 -1
  94. package/dist/utility.d.ts +8 -1
  95. package/dist/utility.js +68 -1
  96. package/dist/utility.js.map +1 -1
  97. package/dist/val/AggFuncVal.d.ts +44 -0
  98. package/dist/val/AggFuncVal.js +364 -0
  99. package/dist/val/AggFuncVal.js.map +1 -0
  100. package/dist/val/ArithFuncVal.d.ts +31 -0
  101. package/dist/val/ArithFuncVal.js +62 -0
  102. package/dist/val/ArithFuncVal.js.map +1 -0
  103. package/dist/val/BagVal.d.ts +7 -0
  104. package/dist/val/BagVal.js +150 -11
  105. package/dist/val/BagVal.js.map +1 -1
  106. package/dist/val/CloseFuncVal.js +9 -1
  107. package/dist/val/CloseFuncVal.js.map +1 -1
  108. package/dist/val/ConjunctVal.d.ts +1 -1
  109. package/dist/val/ConjunctVal.js +19 -0
  110. package/dist/val/ConjunctVal.js.map +1 -1
  111. package/dist/val/ConstraintVal.d.ts +7 -1
  112. package/dist/val/ConstraintVal.js +523 -45
  113. package/dist/val/ConstraintVal.js.map +1 -1
  114. package/dist/val/ContainerKindVal.d.ts +35 -0
  115. package/dist/val/ContainerKindVal.js +99 -0
  116. package/dist/val/ContainerKindVal.js.map +1 -0
  117. package/dist/val/CopyFuncVal.d.ts +1 -2
  118. package/dist/val/CopyFuncVal.js.map +1 -1
  119. package/dist/val/Decimal.d.ts +1 -0
  120. package/dist/val/Decimal.js +13 -0
  121. package/dist/val/Decimal.js.map +1 -1
  122. package/dist/val/DeprecateFuncVal.d.ts +11 -0
  123. package/dist/val/DeprecateFuncVal.js +47 -0
  124. package/dist/val/DeprecateFuncVal.js.map +1 -0
  125. package/dist/val/DisjunctVal.d.ts +1 -2
  126. package/dist/val/DisjunctVal.js +269 -53
  127. package/dist/val/DisjunctVal.js.map +1 -1
  128. package/dist/val/EachFuncVal.d.ts +15 -0
  129. package/dist/val/EachFuncVal.js +75 -0
  130. package/dist/val/EachFuncVal.js.map +1 -0
  131. package/dist/val/ExpectVal.js +62 -4
  132. package/dist/val/ExpectVal.js.map +1 -1
  133. package/dist/val/FeatureVal.js +1 -1
  134. package/dist/val/FeatureVal.js.map +1 -1
  135. package/dist/val/FilterFuncVal.d.ts +15 -0
  136. package/dist/val/FilterFuncVal.js +91 -0
  137. package/dist/val/FilterFuncVal.js.map +1 -0
  138. package/dist/val/FuncBaseVal.d.ts +7 -1
  139. package/dist/val/FuncBaseVal.js +183 -2
  140. package/dist/val/FuncBaseVal.js.map +1 -1
  141. package/dist/val/GraphAtomVal.d.ts +39 -0
  142. package/dist/val/GraphAtomVal.js +184 -0
  143. package/dist/val/GraphAtomVal.js.map +1 -0
  144. package/dist/val/HideFuncVal.js.map +1 -1
  145. package/dist/val/JunctionVal.js +24 -1
  146. package/dist/val/JunctionVal.js.map +1 -1
  147. package/dist/val/KeyFuncVal.d.ts +1 -1
  148. package/dist/val/KeyFuncVal.js +38 -30
  149. package/dist/val/KeyFuncVal.js.map +1 -1
  150. package/dist/val/ListVal.js +94 -14
  151. package/dist/val/ListVal.js.map +1 -1
  152. package/dist/val/LowerFuncVal.js.map +1 -1
  153. package/dist/val/MapVal.d.ts +1 -0
  154. package/dist/val/MapVal.js +146 -14
  155. package/dist/val/MapVal.js.map +1 -1
  156. package/dist/val/MatchFuncVal.d.ts +15 -0
  157. package/dist/val/MatchFuncVal.js +107 -0
  158. package/dist/val/MatchFuncVal.js.map +1 -0
  159. package/dist/val/MoveFuncVal.js.map +1 -1
  160. package/dist/val/NilVal.js +24 -0
  161. package/dist/val/NilVal.js.map +1 -1
  162. package/dist/val/OpBaseVal.d.ts +1 -1
  163. package/dist/val/OpBaseVal.js +19 -1
  164. package/dist/val/OpBaseVal.js.map +1 -1
  165. package/dist/val/OpenFuncVal.js +4 -1
  166. package/dist/val/OpenFuncVal.js.map +1 -1
  167. package/dist/val/PackFuncVal.d.ts +15 -0
  168. package/dist/val/PackFuncVal.js +108 -0
  169. package/dist/val/PackFuncVal.js.map +1 -0
  170. package/dist/val/PathFuncVal.d.ts +2 -2
  171. package/dist/val/PathFuncVal.js +75 -16
  172. package/dist/val/PathFuncVal.js.map +1 -1
  173. package/dist/val/PathVal.d.ts +25 -0
  174. package/dist/val/PathVal.js +150 -0
  175. package/dist/val/PathVal.js.map +1 -0
  176. package/dist/val/PlaceVal.d.ts +13 -0
  177. package/dist/val/PlaceVal.js +131 -0
  178. package/dist/val/PlaceVal.js.map +1 -0
  179. package/dist/val/PlusOpVal.d.ts +2 -1
  180. package/dist/val/PlusOpVal.js +61 -37
  181. package/dist/val/PlusOpVal.js.map +1 -1
  182. package/dist/val/PrefFuncVal.js.map +1 -1
  183. package/dist/val/PrefVal.d.ts +4 -2
  184. package/dist/val/PrefVal.js +223 -41
  185. package/dist/val/PrefVal.js.map +1 -1
  186. package/dist/val/RecurseVal.d.ts +19 -0
  187. package/dist/val/RecurseVal.js +217 -0
  188. package/dist/val/RecurseVal.js.map +1 -0
  189. package/dist/val/RefVal.d.ts +3 -2
  190. package/dist/val/RefVal.js +181 -66
  191. package/dist/val/RefVal.js.map +1 -1
  192. package/dist/val/ReferFuncVal.d.ts +59 -0
  193. package/dist/val/ReferFuncVal.js +604 -0
  194. package/dist/val/ReferFuncVal.js.map +1 -0
  195. package/dist/val/ScalarKindVal.d.ts +4 -3
  196. package/dist/val/ScalarKindVal.js +12 -12
  197. package/dist/val/ScalarKindVal.js.map +1 -1
  198. package/dist/val/SuperFuncVal.d.ts +4 -2
  199. package/dist/val/SuperFuncVal.js +118 -14
  200. package/dist/val/SuperFuncVal.js.map +1 -1
  201. package/dist/val/TopVal.d.ts +1 -1
  202. package/dist/val/TopVal.js.map +1 -1
  203. package/dist/val/TypeFuncVal.js.map +1 -1
  204. package/dist/val/UpperFuncVal.js.map +1 -1
  205. package/dist/val/Val.d.ts +7 -1
  206. package/dist/val/Val.js +170 -2
  207. package/dist/val/Val.js.map +1 -1
  208. package/dist/val/VarVal.js.map +1 -1
  209. package/dist/val/arith.d.ts +6 -0
  210. package/dist/val/arith.js +173 -0
  211. package/dist/val/arith.js.map +1 -0
  212. package/dist/vet.d.ts +45 -0
  213. package/dist/vet.js +802 -0
  214. package/dist/vet.js.map +1 -0
  215. package/dist/view.d.ts +85 -0
  216. package/dist/view.js +1948 -0
  217. package/dist/view.js.map +1 -0
  218. package/dist/walk.d.ts +2 -0
  219. package/dist/walk.js +91 -0
  220. package/dist/walk.js.map +1 -0
  221. package/grammar/aontu.gbnf +138 -0
  222. package/grammar/aontu.lark +118 -0
  223. package/grammar/aontu.tmLanguage.json +184 -0
  224. package/package.json +30 -8
  225. package/skill/SKILL.md +37 -0
  226. package/skill/error-codes.md +62 -0
  227. package/skill/examples.md +99 -0
  228. package/skill/grammar-card.md +56 -0
  229. package/src/agentsmd.ts +135 -0
  230. package/src/aontu.ts +166 -5
  231. package/src/cli.ts +3324 -72
  232. package/src/ctx.ts +93 -1
  233. package/src/diff.ts +198 -0
  234. package/src/err.ts +50 -8
  235. package/src/graph.ts +185 -0
  236. package/src/hcanon.ts +167 -0
  237. package/src/hints.ts +291 -8
  238. package/src/jsonschema.ts +552 -0
  239. package/src/lang.ts +1049 -110
  240. package/src/lsp.ts +351 -49
  241. package/src/mcp-server.ts +187 -0
  242. package/src/mcp.ts +1130 -0
  243. package/src/mod-tool.ts +718 -0
  244. package/src/mod.ts +453 -0
  245. package/src/patch.ts +628 -0
  246. package/src/provenance.ts +437 -0
  247. package/src/query.ts +381 -0
  248. package/src/reach.ts +213 -0
  249. package/src/relation.ts +298 -0
  250. package/src/report-sarif.ts +137 -0
  251. package/src/sig.ts +345 -0
  252. package/src/sigdecl.ts +11 -0
  253. package/src/siggate.ts +144 -0
  254. package/src/site.ts +36 -1
  255. package/src/std.ts +131 -0
  256. package/src/subsume.ts +739 -0
  257. package/src/trim.ts +195 -0
  258. package/src/tsconfig.json +10 -4
  259. package/src/type.ts +51 -2
  260. package/src/unify.ts +253 -38
  261. package/src/utility.ts +79 -1
  262. package/src/val/AggFuncVal.ts +546 -0
  263. package/src/val/ArithFuncVal.ts +108 -0
  264. package/src/val/BagVal.ts +158 -12
  265. package/src/val/CloseFuncVal.ts +9 -1
  266. package/src/val/ConjunctVal.ts +20 -0
  267. package/src/val/ConstraintVal.ts +576 -48
  268. package/src/val/ContainerKindVal.ts +158 -0
  269. package/src/val/CopyFuncVal.ts +0 -1
  270. package/src/val/Decimal.ts +15 -0
  271. package/src/val/DeprecateFuncVal.ts +84 -0
  272. package/src/val/DisjunctVal.ts +282 -59
  273. package/src/val/EachFuncVal.ts +133 -0
  274. package/src/val/ExpectVal.ts +65 -8
  275. package/src/val/FeatureVal.ts +1 -1
  276. package/src/val/FilterFuncVal.ts +154 -0
  277. package/src/val/FuncBaseVal.ts +206 -2
  278. package/src/val/GraphAtomVal.ts +264 -0
  279. package/src/val/HideFuncVal.ts +0 -2
  280. package/src/val/JunctionVal.ts +24 -1
  281. package/src/val/KeyFuncVal.ts +39 -35
  282. package/src/val/ListVal.ts +101 -15
  283. package/src/val/LowerFuncVal.ts +0 -1
  284. package/src/val/MapVal.ts +153 -14
  285. package/src/val/MatchFuncVal.ts +176 -0
  286. package/src/val/MoveFuncVal.ts +0 -2
  287. package/src/val/NilVal.ts +25 -0
  288. package/src/val/OpBaseVal.ts +20 -1
  289. package/src/val/OpenFuncVal.ts +4 -2
  290. package/src/val/PackFuncVal.ts +175 -0
  291. package/src/val/PathFuncVal.ts +107 -20
  292. package/src/val/PathVal.ts +221 -0
  293. package/src/val/PlaceVal.ts +193 -0
  294. package/src/val/PlusOpVal.ts +67 -39
  295. package/src/val/PrefFuncVal.ts +0 -1
  296. package/src/val/PrefVal.ts +251 -60
  297. package/src/val/RecurseVal.ts +285 -0
  298. package/src/val/RefVal.ts +183 -78
  299. package/src/val/ReferFuncVal.ts +732 -0
  300. package/src/val/ScalarKindVal.ts +12 -13
  301. package/src/val/SuperFuncVal.ts +137 -13
  302. package/src/val/TopVal.ts +1 -2
  303. package/src/val/TypeFuncVal.ts +0 -2
  304. package/src/val/UpperFuncVal.ts +0 -1
  305. package/src/val/Val.ts +216 -4
  306. package/src/val/VarVal.ts +0 -1
  307. package/src/val/arith.ts +319 -0
  308. package/src/vet.ts +1025 -0
  309. package/src/view.ts +2563 -0
  310. package/src/walk.ts +99 -0
package/src/vet.ts ADDED
@@ -0,0 +1,1025 @@
1
+ /* Copyright (c) 2025 Richard Rodger, MIT License */
2
+
3
+ // The validation verb: check a data document against a schema document
4
+ // and return a MACHINE-READABLE report (G2 phase 2,
5
+ // docs/capability-review/g2-validation-verb.md).
6
+ //
7
+ // This module is the engine only. The CLI verb, the JSON/SARIF
8
+ // renderers and `--watch` are phase 3 and 5; the Go port is phase 4.
9
+ // Keeping the engine transport-free is the split lsp.ts already uses,
10
+ // and for the same reason: it is the part that has to be unit-testable
11
+ // and embeddable.
12
+ //
13
+ // Two properties of the engine shape everything here, and both were
14
+ // probed rather than assumed:
15
+ //
16
+ // - A CONTRADICTION surfaces as a NilVal in the unified tree, so
17
+ // conflict findings come from a tree walk (the `walkNils` pattern
18
+ // lsp.ts already uses, generalised).
19
+ // - INCOMPLETENESS does not. `{name:"auth"}` against
20
+ // `{name:string, port:integer}` unifies cleanly and leaves
21
+ // `port:integer` standing — no nil, no error. It surfaces only when
22
+ // something tries to GENERATE, which is why vet runs a generate
23
+ // check in an isolated collect context and reads the
24
+ // `incomplete`-class errors out of it. The two verdicts the report
25
+ // distinguishes therefore come from two different mechanisms.
26
+
27
+ import type { TrustOptions, Val } from './type'
28
+
29
+ import { Aontu } from './aontu'
30
+ import {
31
+ dirname, isAbsolute, join as pathJoin, relative as pathRelative,
32
+ resolve as pathResolve,
33
+ } from 'node:path'
34
+
35
+ import { descErr, getHint } from './err'
36
+ import { ConjunctVal } from './val/ConjunctVal'
37
+ import { walkVals, collectNils } from './walk'
38
+ import { sizingResidue } from './val/BagVal'
39
+ import { collectDeprecations, walkBagVals, deprecationMessage } from './utility'
40
+ import { subsumeNode, effectiveDefault } from './subsume'
41
+ // The `--at` refusal is the SAME refusal `get` and `why` give for a
42
+ // path that names nothing, down to the "did you mean" -- so it is that
43
+ // one, not a second spelling of it. A cycle in the module graph
44
+ // (query imports anchorAt from here), and a benign one: both sides
45
+ // use the other only from inside a function body, never at load time.
46
+ import { noPathFinding } from './query'
47
+
48
+
49
+ export type VetVerdict = 'valid' | 'invalid' | 'incomplete' | 'error'
50
+
51
+ export type VetRole = 'data' | 'schema'
52
+
53
+ export type VetSite = {
54
+ file: string
55
+ row: number
56
+ col: number
57
+ // The extent in UTF-16 code units, or -1 when unknown — the same
58
+ // "unknown" row and col already use.
59
+ //
60
+ // THIS IS WHAT MAKES A FINDING REPAIRABLE. `value` is the CANON, not
61
+ // the source text: `port: 0x1F` reports canon `31` at column 7, so a
62
+ // consumer replacing `(col, value.length)` writes `port: 5x1F` and
63
+ // corrupts the document. With `len` the span is `(col, 4)` and the
64
+ // replacement is exact.
65
+ //
66
+ // NEVER GUESSED. Where the span is unknown this is -1 and a consumer
67
+ // must not edit — unlike the LSP, which falls back to canon because a
68
+ // wonky highlight is cosmetic while a wrong edit is a lost file.
69
+ // See ts/src/site.ts for what the extent covers.
70
+ len: number
71
+ role: VetRole
72
+ // The SOURCE TEXT the span covers, or '' when unknown.
73
+ //
74
+ // This is what makes the span SELF-VERIFYING, and it is not the same
75
+ // as `value`. For a scalar the two differ by normalisation — `0x1F`
76
+ // has src `0x1F` and value (canon) `31`. For a COMPOUND the span
77
+ // names the opening token only, exactly as row and col always have:
78
+ // a constraint `min(1)` reports src `min`, and a reference `$.b`
79
+ // reports src `$`.
80
+ //
81
+ // So a consumer must read the document at (row, col, len), compare it
82
+ // to `src`, and REFUSE when they differ — and, seeing `min` where it
83
+ // expected `min(1)`, refuse rather than replace the name and orphan
84
+ // the arguments. Without this field that mistake is undetectable.
85
+ src?: string
86
+ value?: string
87
+ }
88
+
89
+ export type VetFinding = {
90
+ code: string
91
+ class: string
92
+ severity: 'error' | 'warning' | 'info'
93
+ path: string
94
+ message: string
95
+
96
+ // THE REPAIR, not just the diagnosis. `message` is one line by
97
+ // design -- it is the headline, and the frames under it are for a
98
+ // human at a terminal -- but for several codes the part that says
99
+ // what to DO about the failure lived only in those frames, so a
100
+ // machine reader (an agent, a CI annotation, an editor) got the
101
+ // complaint and none of the cure. `0d` is the clearest case: the
102
+ // engine refuses a lossy integer literal and the hint names the
103
+ // exact-decimal escape that fixes it. Populated from the shared
104
+ // hints table (ts/src/hints.ts, mirrored by go/hints.go) whenever
105
+ // the code has one; absent when it does not (the report-layer
106
+ // compat_* codes, notably, carry no hint text).
107
+ //
108
+ // Excluded from spec goldens for the same reason `message` is: it is
109
+ // prose, it is long, and the two ports hold it to the byte through
110
+ // the message tests instead of through every vet row.
111
+ hint?: string
112
+
113
+ sites: VetSite[]
114
+ expected?: string
115
+ actual?: string
116
+ note?: string
117
+ }
118
+
119
+ export type VetReport = {
120
+ verdict: VetVerdict
121
+ truncated: boolean
122
+ findings: VetFinding[]
123
+ }
124
+
125
+ export type VetOptions = {
126
+ at?: string // validate against this path of the schema
127
+ closed?: boolean // close() the anchor for this run
128
+ partial?: boolean // residue is not a failure
129
+ maxErrors?: number // cap the finding list (default 20)
130
+ schemaUrl?: string // provenance label for schema sites
131
+ dataUrl?: string // provenance label for data sites
132
+
133
+ // Where each document CAME FROM, used to resolve its relative
134
+ // `@"file"` loads -- the file path, as `Aontu({path})` takes it, not
135
+ // the directory. Vet takes two documents from its caller rather than
136
+ // from the filesystem, so it cannot know this: without it a modular
137
+ // schema resolved its includes against the process working
138
+ // directory, which fails outside that directory and, worse, silently
139
+ // reads a same-named file that happens to sit there. The two
140
+ // documents get their OWN bases, because they need not live in the
141
+ // same place.
142
+ schemaPath?: string
143
+ dataPath?: string
144
+
145
+ // The trust profile this run evaluates under (G5, docs/trust.md).
146
+ // Vet's whole job is to evaluate a document its caller did not
147
+ // write, so the caller must be able to say what that document is
148
+ // allowed to reach: without this the include chain is the default
149
+ // one, and `@"../../etc/passwd.aon"` in hostile data reads whatever
150
+ // the validating process can. (`@"x.js"` no longer executes --
151
+ // ADR-012 refuses the extension -- but reading is enough.) A server
152
+ // passes `{include:'none'}`.
153
+ trust?: TrustOptions
154
+ }
155
+
156
+
157
+ // The default cap, exported because the CLI applies it to the WHOLE
158
+ // report across several data files and must not carry a second copy of
159
+ // the number (ts/src/cli.ts).
160
+ export const VET_MAX_ERRORS = 20
161
+
162
+ const DEFAULT_SCHEMA_URL = 'schema'
163
+ const DEFAULT_DATA_URL = 'data'
164
+
165
+
166
+ // Every site in a freshly parsed tree carries the same url, and for a
167
+ // bare parse that url is the empty string: `site.url` is only populated
168
+ // by the multisource loader (ts/src/lang.ts). Vet takes two documents
169
+ // from its CALLER, not from the filesystem, so it stamps provenance
170
+ // itself — which is what lets the report assign site ROLES by
171
+ // provenance rather than by NilVal's source-order heuristic, exactly as
172
+ // the design requires.
173
+ // EVERY SITE NAMES THE FILE WHOSE TEXT IT EXCERPTS (the review's
174
+ // finding F, use-cases/BUGS.md §25). The parser already names the file
175
+ // each value was read from -- a value loaded through `@"lib/types.aon"`
176
+ // carries that path, with that file's row and column -- and this walk
177
+ // used to OVERWRITE every url with the entry document's name. The
178
+ // coordinates stayed the included file's, so a finding cited
179
+ // `entry.aon:3:7` for text that lives three files away, at a line the
180
+ // entry may not even have. A repair agent that follows the site edits
181
+ // the wrong file.
182
+ //
183
+ // Only the values that carry no name of their own are stamped: those
184
+ // are the ones the engine minted rather than read, and the entry is
185
+ // the honest name for them. The urls actually seen are collected, so
186
+ // the report can still tell WHICH DOCUMENT a site belongs to without
187
+ // pretending they all came from one file -- see roleOf.
188
+ function stampUrl(v: any, url: string, seen?: Set<string>): Set<string> {
189
+ const urls = seen ?? new Set<string>()
190
+ urls.add(url)
191
+ walkVals(v, (n: any) => {
192
+ // UNDEFINED counts as unstamped, not just empty: the parser leaves
193
+ // the url undefined on one of its two paths (ts/src/lang.ts), and
194
+ // treating that as a name would put `undefined` in the url set --
195
+ // where the OTHER document's unstamped values would then match it,
196
+ // and every site would read `data`.
197
+ if (null == n.site.url || '' === n.site.url) {
198
+ n.site.url = url
199
+ }
200
+ urls.add(n.site.url)
201
+ return true
202
+ }, new Set())
203
+ return urls
204
+ }
205
+
206
+
207
+ // The provenance a report projects its sites through: which urls
208
+ // belong to the DATA document, and how the caller reached each of the
209
+ // two documents (which is what says how to NAME a third file either of
210
+ // them included).
211
+ type Prov = {
212
+ // The urls the data walk reached. Roles are decided by membership,
213
+ // on the RAW url -- never by a name comparison.
214
+ data: Set<string>
215
+ schemaUrl?: string
216
+ schemaPath?: string
217
+ dataUrl?: string
218
+ dataPath?: string
219
+ }
220
+
221
+
222
+ // A FILE THE READER CAN OPEN. The parser resolves an include to an
223
+ // absolute path, which is the right identity (two files loading the
224
+ // same library by different relative spellings must be one file) and
225
+ // the wrong NAME: a report whose entry reads `contract.aon` and whose
226
+ // included site reads `/home/someone/checkout/types.aon` is a report
227
+ // that cannot be uploaded as SARIF, diffed between machines, or read
228
+ // beside the command that produced it.
229
+ //
230
+ // So an included file is named as the ENTRY'S OWN NAME reaches it:
231
+ // relative to the entry's directory, then re-anchored on however the
232
+ // caller spelled the entry. `vet contract.aon` names `types.aon`;
233
+ // `vet a/b/contract.aon` names `a/b/types.aon`; an absolute entry
234
+ // keeps absolute includes. A caller who passed no path at all has no
235
+ // base to relativise against and gets the url unchanged.
236
+ export function displayFile(
237
+ url: string, label: string, path?: string): string {
238
+ if (url === label || null == path || '' === url || !isAbsolute(url)) {
239
+ return url
240
+ }
241
+ const rel = pathRelative(dirname(pathResolve(path)), url)
242
+ const dir = dirname(label)
243
+ return '.' === dir ? rel : pathJoin(dir, rel)
244
+ }
245
+
246
+
247
+ // The name for one site, taken from the document the site BELONGS to
248
+ // -- which is the role, already decided by url-set membership. Doing
249
+ // it here rather than from a map built at stamping time is not a
250
+ // shortcut: a nil's operands are off the tree by the time the report
251
+ // is built, so a value first seen during the MEET (the commonest
252
+ // schema site there is) would be missing from any such map.
253
+ function displayOf(file: string, role: VetRole, prov: Prov): string {
254
+ return 'data' === role
255
+ ? displayFile(file, prov.dataUrl ?? file, prov.dataPath)
256
+ : displayFile(file, prov.schemaUrl ?? file, prov.schemaPath)
257
+ }
258
+
259
+
260
+ // The ROLE of a site: which of the two documents it belongs to. Not a
261
+ // name comparison -- a data document may itself include another file,
262
+ // and that file's values are still data. Membership of the url set the
263
+ // stamping walk collected is the question, and the answer is `schema`
264
+ // for anything the data walk never reached (an engine-minted value
265
+ // stamped with the schema entry, say).
266
+ function roleOf(file: string, prov: Prov): VetRole {
267
+ return prov.data.has(file) ? 'data' : 'schema'
268
+ }
269
+
270
+
271
+ // `$.a.b`, and `$` for the root. Deliberately NOT delimiter-escaped: a
272
+ // map key may contain any character, including every separator a
273
+ // compact summary might pick, so the path is carried as a JSON string
274
+ // and never parsed back out of a larger token.
275
+ function pathText(path?: string[]): string {
276
+ return '$' + (null != path && 0 < path.length ? '.' + path.join('.') : '')
277
+ }
278
+
279
+
280
+ // `secondary` is the only operand that can be absent — a `closed` or an
281
+ // incomplete finding has one side, a two-site conflict has both — so
282
+ // this is the one nullable input, and every Val that does arrive
283
+ // carries a site and a canon.
284
+ function siteOf(v: any, prov: Prov): VetSite | undefined {
285
+ if (null == v) {
286
+ return undefined
287
+ }
288
+ // The report's `file` is whatever the site carries, and by the time a
289
+ // site reaches a report that is always a stamped name: vet walks both
290
+ // documents before they meet, and the walk reaches the off-peg values
291
+ // a finding can name (ts/src/walk.ts). A consumer therefore reads
292
+ // `file` without a presence check, and the Go port -- whose field is
293
+ // a plain string -- writes the same key.
294
+ //
295
+ // NOT coalesced. The parser leaves the url undefined on one of its
296
+ // two paths (ts/src/lang.ts), and a `?? ''` here would be dead code
297
+ // that hides it: if a value ever reaches a report unstamped, the two
298
+ // ports should disagree loudly rather than quietly agree on an empty
299
+ // name that neither of them meant.
300
+ const file = v.site.url
301
+ // NAMED for the reader, ROLED by the raw url: the two questions are
302
+ // different, and only the first is about how the file is spelled
303
+ // (see displayFile).
304
+ const role = roleOf(file, prov)
305
+ return {
306
+ file: displayOf(file, role, prov),
307
+ row: v.site.row,
308
+ col: v.site.col,
309
+ len: v.site.len,
310
+ role,
311
+ src: v.site.src,
312
+ value: v.canon,
313
+ }
314
+ }
315
+
316
+
317
+ // The data site first — it is the thing to fix — then the schema site.
318
+ // The underlying NilVal fields are untouched: this is a report-layer
319
+ // projection, so the existing error.tsv assertions do not move.
320
+ function sitesOf(nil: any, prov: Prov): VetSite[] {
321
+ // `?? nil`: a failure raised about a CONSTRUCT rather than about a
322
+ // failed meet -- a lossy integer literal, say -- carries no operands
323
+ // at all, and reporting it about ITSELF is what ctx.adderr already
324
+ // does for the same reason. Without the fallback the report built a
325
+ // site out of `undefined` and threw while partitioning it, which is
326
+ // the one thing vet promises not to do: a bad value in the data is
327
+ // DATA, and the caller gets a report.
328
+ const sites: VetSite[] = [siteOf(nil.primary ?? nil, prov) as VetSite]
329
+
330
+ const secondary = siteOf(nil.secondary, prov)
331
+ if (null != secondary) {
332
+ sites.push(secondary)
333
+ }
334
+
335
+ // Partitioned rather than sorted: which of the two NilVal operands is
336
+ // `primary` follows source order within one document, which says
337
+ // nothing useful when one side is a schema and the other is data.
338
+ return [
339
+ ...sites.filter((s) => 'data' === s.role),
340
+ ...sites.filter((s) => 'schema' === s.role),
341
+ ]
342
+ }
343
+
344
+
345
+ // The message text is MATERIALISED on demand, exactly as handleErrors
346
+ // materialises one before a caller sees it: makeNilErr defers it
347
+ // because most NilVals are transient and never rendered, and only the
348
+ // throwing path asks for it. Without this a finding could carry an
349
+ // empty `message` -- which is what the incomplete half of every report
350
+ // did, and what any nil built during the PARSE of a document did.
351
+ function materialise(nil: any, ctx: any): void {
352
+ if (null == nil.msg || '' === nil.msg) {
353
+ descErr(nil, ctx)
354
+ }
355
+ }
356
+
357
+
358
+ // The terminal colour escapes the parser puts in its message text. A
359
+ // RegExp built from a string, not a literal: the escape is a control
360
+ // character, and spelling it `\u001b` keeps the source readable.
361
+ const ANSI_RE = new RegExp('\u001b\\[[0-9;]*m', 'g')
362
+
363
+ function stripAnsi(s: string): string {
364
+ return s.replace(ANSI_RE, '')
365
+ }
366
+
367
+
368
+ function findingOf(nil: any, prov: Prov): VetFinding {
369
+ const details = nil.details ?? {}
370
+ const finding: VetFinding = {
371
+ code: nil.why,
372
+ class: nil.class,
373
+ severity: 'error',
374
+ path: pathText(nil.path),
375
+ // The HEADLINE only, WITHOUT ANSI: the frames below it are for a
376
+ // human reading a terminal, and the first line is the part the two
377
+ // ports hold to byte parity. Materialised before this runs, so it
378
+ // is always there (see materialise above). The escapes matter for
379
+ // one family only -- a parse failure's text comes from the parser,
380
+ // which colours its marker -- and a machine-readable report is no
381
+ // place for terminal control codes.
382
+ message: stripAnsi(nil.msg.split('\n')[0]),
383
+ sites: sitesOf(nil, prov),
384
+ }
385
+
386
+ // The hint, whole, with its detail placeholders filled in exactly as
387
+ // the terminal frame fills them. Trailing whitespace is dropped
388
+ // because it is spacing for the frame that used to follow it, not
389
+ // part of the text; the deliberate blank lines INSIDE a hint are
390
+ // `\n \n` and survive.
391
+ const hint = getHint(nil.why, nil.details)
392
+ if (null != hint && '' !== hint) {
393
+ finding.hint = stripAnsi(hint).replace(/\s+$/, '')
394
+ }
395
+
396
+ // `expected`/`actual` are the admissible-alternatives contract, and
397
+ // the constraint algebra already produces them: G1's atoms attach the
398
+ // normalised residual and the offending value, and `must` attaches
399
+ // the author's message. Read them where they are rather than
400
+ // re-deriving them here.
401
+ if ('string' === typeof details.expected) {
402
+ finding.expected = details.expected
403
+ }
404
+ if ('string' === typeof details.actual) {
405
+ finding.actual = details.actual
406
+ }
407
+ if ('string' === typeof details.message) {
408
+ finding.note = details.message
409
+ }
410
+
411
+ return finding
412
+ }
413
+
414
+
415
+ // Findings are sorted BY VET, not by the walk. The underlying walk
416
+ // iterates raw object keys and the two hosts disagree about their order
417
+ // — `10:… 9:…` yields ["9","10"] in JavaScript, which hoists
418
+ // integer-like keys, against Go's insertion order (ts/src/keyorder.ts
419
+ // exists for exactly this) — so an unsorted report could never be in
420
+ // cross-port parity.
421
+ //
422
+ // The order is by data site (file, row, column), then code, then path.
423
+ // It is carried in ONE key string rather than a cascade of comparisons:
424
+ // the row and column are zero-padded so lexicographic order is numeric
425
+ // order, and NUL joins the fields because no field can contain one.
426
+ // A cascade would need a test per tie-breaker to stay honest; a key
427
+ // needs none, and cannot disagree with itself.
428
+ const ORDER_PAD = 9
429
+
430
+
431
+ function pad(n: number): string {
432
+ return String(n).padStart(ORDER_PAD, '0')
433
+ }
434
+
435
+
436
+ // The walk index is the LAST field, which makes every key unique and
437
+ // the sort below total: two findings can otherwise share everything the
438
+ // key carries — same data site, same code, same path — and a comparator
439
+ // that has to answer "equal" is one more thing to get right in two
440
+ // languages. With the index appended, ties simply keep walk order, in
441
+ // both ports, by construction rather than by the sort's promises.
442
+ function orderKey(f: VetFinding, index: number): string {
443
+ const site = f.sites[0]
444
+ return [
445
+ site.file,
446
+ pad(site.row),
447
+ pad(site.col),
448
+ f.code,
449
+ f.path,
450
+ pad(index),
451
+ ].join('\u0000')
452
+ }
453
+
454
+
455
+ // A DOCUMENT THAT DOES NOT STAND UP, in the finding shape (the
456
+ // review's finding F). `trim` and `relations` answered an unusable
457
+ // document with `verdict: error` and an EMPTY list: the caller learned
458
+ // that something was wrong and nothing about what, which is the one
459
+ // thing a repair loop cannot work with. Both verbs take ONE document,
460
+ // so there is no role to decide -- the document is the thing being
461
+ // checked and the thing to edit, which is what `data` means here.
462
+ //
463
+ // The engine's own first error IS the finding: these verbs add nothing
464
+ // to a diagnosis the evaluator already made, and the FIRST is enough
465
+ // because everything after it is a consequence.
466
+ //
467
+ // The Go twin is failureFinding in go/vet.go.
468
+ export function failureFinding(
469
+ ctx: any, url?: string, failed?: any): VetFinding {
470
+ // ctx.err IS SOMETIMES EMPTY, and the comment that used to stand here
471
+ // said otherwise (use-cases/BUGS.md §43). `&: id(root)` fails with a
472
+ // NIL ROOT and NO COLLECTED ERROR -- the id-spread refusal is the
473
+ // root itself -- and every verb that reports "this document does not
474
+ // stand up" then read `ctx.err[0]` as undefined and died: a TypeError
475
+ // out of `relations`, `reaches` and `jsonschema` in TypeScript, a
476
+ // panic in Go. The one shape where finding F's own invariant, that a
477
+ // document which does not stand up SAYS SO in the finding shape, was
478
+ // answered with a stack trace.
479
+ //
480
+ // `failed` is the caller's own root -- every caller has it, and its
481
+ // condition is `0 < ctx.err.length || root.isNil`, so when the first
482
+ // half is false the second holds and the root IS the reason.
483
+ const nil: any = ctx.err[0] ?? failed
484
+ materialise(nil, ctx)
485
+
486
+ // STAMPED, as vet stamps both documents before they meet: siteOf does
487
+ // not coalesce a missing name (deliberately -- see there), so a site
488
+ // that reached the report unstamped would carry `file: undefined`.
489
+ // The three Vals a finding can name are the nil and its two operands,
490
+ // and the url set collects whatever name each already had, so a value
491
+ // read from an included file keeps that file's name and still counts
492
+ // as part of the one document being checked.
493
+ const at = url ?? ''
494
+ const urls = new Set([at])
495
+ for (const v of [nil, nil.primary, nil.secondary]) {
496
+ if (null == v || null == v.site) {
497
+ continue
498
+ }
499
+ if (null == v.site.url || '' === v.site.url) {
500
+ v.site.url = at
501
+ }
502
+ urls.add(v.site.url)
503
+ }
504
+
505
+ return findingOf(nil, { data: urls })
506
+ }
507
+
508
+
509
+ // Walk the evaluated schema to the anchor path. `$` and `$.a.b` are
510
+ // both accepted, as is the bare `a.b` a shell is likely to hand over
511
+ // unquoted.
512
+ export function anchorAt(root: any, at: string): Val | undefined {
513
+ const trimmed = at.startsWith('$') ? at.slice(1) : at
514
+ const parts = trimmed.split('.').filter((p) => '' !== p)
515
+
516
+ let node: any = root
517
+ for (const part of parts) {
518
+ // A SIZING RESIDUE IS ITS CONTAINER, plus a note about what the
519
+ // container must still satisfy (use-cases/BUGS.md §16). The path
520
+ // steps through it: `$.a.ports.0.port` names the same node whether
521
+ // or not `ports` still carries a `unique()`, and an anchor that
522
+ // stopped here would report `no_path` for a key the document
523
+ // plainly has.
524
+ node = throughResidue(node)
525
+
526
+ // TYPE-DIRECTED, not a property lookup on whatever `peg` happens to
527
+ // be. An anchor is a STRUCTURAL path into the schema — the same
528
+ // thing a reference means by `$.a.b` — so it walks map keys and
529
+ // list indices, and stops at anything else.
530
+ //
531
+ // Indexing the peg generically walked much further than that: into
532
+ // a junction's branches (`a:1|2` with `--at $.a.0` validated
533
+ // against ONE branch), into a constraint's atom arguments (so
534
+ // `min(2)` with `--at $.a.0` reported the bound's own argument as
535
+ // the truth), into a pref's wrapped value through the literal key
536
+ // `peg`, and into an array's `length` — that last one handing back
537
+ // a JavaScript NUMBER as the anchor, after which every document
538
+ // whatsoever came back valid. The Go port has always been
539
+ // type-directed here; this is the canonical side moving to it.
540
+ if (true === node?.isMap) {
541
+ const peg = node.peg
542
+ if (null == peg || !Object.prototype.hasOwnProperty.call(peg, part)) {
543
+ return undefined
544
+ }
545
+ node = peg[part]
546
+ }
547
+ else if (true === node?.isList) {
548
+ // CANONICAL DECIMAL, the spelling a reference uses for a list
549
+ // index (`0`, or a non-zero digit run) -- so `$.a.01` names
550
+ // nothing here exactly as it names nothing there.
551
+ const peg = node.peg
552
+ const index = Number(part)
553
+ if (!/^(0|[1-9][0-9]*)$/.test(part) ||
554
+ !Array.isArray(peg) || peg.length <= index) {
555
+ return undefined
556
+ }
557
+ node = peg[index]
558
+ }
559
+ else {
560
+ return undefined
561
+ }
562
+ }
563
+
564
+ // THE ANCHOR KEEPS ITS ATOM. Stepping THROUGH a residue is right --
565
+ // `$.x.a` names a key of the container whatever the container still
566
+ // has to satisfy -- but ARRIVING at one and handing back the bare
567
+ // container drops a constraint the author wrote, so `--at $.x` vetted
568
+ // clean against a `length` the evaluator enforces. The residue is the
569
+ // honest schema for the node: the meet drives it, and generation
570
+ // settles it, exactly as it does without an anchor.
571
+ return node
572
+ }
573
+
574
+
575
+ // The container inside a settled sizing residue, or the value itself.
576
+ function throughResidue(v: any): any {
577
+ return sizingResidue(v)?.bag ?? v
578
+ }
579
+
580
+
581
+ // Validate `dataSrc` against `schemaSrc`.
582
+ //
583
+ // Never throws for findings: a contradiction in the data is DATA, and
584
+ // the caller gets a report. It throws only when the caller's own inputs
585
+ // are unusable — which is why an unusable schema is a verdict (`error`)
586
+ // rather than an exception too: "the schema is broken" is a fact the
587
+ // agent loop needs to branch on, not an exceptional condition.
588
+ export function vet(
589
+ schemaSrc: string, dataSrc: string, opts?: VetOptions): VetReport {
590
+ const options = opts ?? {}
591
+ const schemaUrl = options.schemaUrl ?? DEFAULT_SCHEMA_URL
592
+ const dataUrl = options.dataUrl ?? DEFAULT_DATA_URL
593
+ const maxErrors = options.maxErrors ?? VET_MAX_ERRORS
594
+
595
+ // ONE instance, two bases: the path rides on each CALL rather than on
596
+ // the constructor, because the schema and the data may live in
597
+ // different directories (Lang.parse takes `opts.path` per parse).
598
+ const aontu = new Aontu(
599
+ null == options.trust ? undefined : { trust: options.trust })
600
+ const schemaOpts = null == options.schemaPath ?
601
+ undefined : { path: options.schemaPath }
602
+ const dataOpts = null == options.dataPath ?
603
+ undefined : { path: options.dataPath }
604
+
605
+ // 1. The schema alone. If it does not stand up on its own, the data
606
+ // is never blamed for it.
607
+ const schemaCtx = aontu.ctx({ collect: true })
608
+ const schemaVal: any = aontu.unify(schemaSrc, schemaOpts, schemaCtx)
609
+ if (0 < schemaCtx.err.length || true === schemaVal?.isNil) {
610
+ // A broken schema REPORTS, exactly as broken data does. It used to
611
+ // answer `findings: []` with exit 4 and nothing else, in both
612
+ // ports: the engine had collected the fault and vet threw it away,
613
+ // so an agent -- or a person -- was told the schema was broken and
614
+ // not what or where. The verdict stays `error` (the fault is in
615
+ // the truth, not in the data, and that distinction is the whole
616
+ // point of the class), but the finding travels with it.
617
+ //
618
+ // The FIRST error only, and the data path's reasoning applies
619
+ // unchanged: later errors in a document that does not stand up are
620
+ // consequences of the first rather than separate things to fix.
621
+ //
622
+ // ONE OF THE TWO IS ALWAYS THERE, and both are nils: the branch
623
+ // condition admits a collected error or a nil root, and every
624
+ // value on `schemaCtx.err` is a NilVal. There is no third case, so
625
+ // there is no guard here -- a guard that cannot fire is dead code,
626
+ // and dead code is what ADR-002 exists to keep out. (One stood
627
+ // here and the TypeScript line report called it covered; the Go
628
+ // gate, which measures blocks, refused the twin.)
629
+ const failure: any =
630
+ 0 < schemaCtx.err.length ? schemaCtx.err[0] : schemaVal
631
+ // The normal path stamps both documents before they meet
632
+ // (stampUrl(anchor...) below), and this early return never reaches
633
+ // it, so it stamps what it is about to report: the unified root,
634
+ // and the failure itself -- a COLLECTED error is minted during
635
+ // unification and hangs off no tree, so nothing else would name
636
+ // it. The walk reaches a failure's operands (ts/src/walk.ts),
637
+ // which is what makes the sites say which file.
638
+ stampUrl(schemaVal, schemaUrl)
639
+ stampUrl(failure, schemaUrl)
640
+ materialise(failure, schemaCtx)
641
+ return {
642
+ verdict: 'error',
643
+ truncated: false,
644
+ // A schema that does not stand up: nothing here is data, so the
645
+ // data-url set is empty and every site reads `schema`.
646
+ findings: [findingOf(failure, { data: new Set<string>() })],
647
+ }
648
+ }
649
+
650
+ // 2. The anchor: the whole schema, or the value at `--at`.
651
+ let anchor: any = schemaVal
652
+ if (null != options.at) {
653
+ anchor = anchorAt(schemaVal, options.at)
654
+ if (null == anchor) {
655
+ // AND IT SAYS WHICH SEGMENT. `--at` naming nothing is an error
656
+ // verdict for the same reason a broken schema is -- the run
657
+ // could not be set up from the truth's side -- and it reports
658
+ // for the same reason too: a caller handed exit 4 and an empty
659
+ // list has nothing to act on.
660
+ return {
661
+ verdict: 'error',
662
+ truncated: false,
663
+ findings: [noPathFinding(schemaVal, options.at)],
664
+ }
665
+ }
666
+ }
667
+
668
+ // 3. Both documents get their provenance stamped BEFORE they meet, so
669
+ // every site in the result knows which document it came from.
670
+ const dataCtx = aontu.ctx({ collect: true })
671
+ const dataVal: any = aontu.parse(dataSrc, dataOpts, dataCtx)
672
+ if (0 < dataCtx.err.length || null == dataVal) {
673
+ // A DATA DOCUMENT THAT WILL NOT PARSE IS THE DATA'S FAULT, and the
674
+ // report says so: verdict `invalid`, with a finding carrying the
675
+ // parser's own code and a site in the data. `error` is left to mean
676
+ // what the exit table says it means -- the run could not be set up
677
+ // from the SCHEMA side.
678
+ //
679
+ // The engine already answered it this way one character earlier: a
680
+ // refused CONSTRUCT (`a: 9007199254740993`) reaches the tree as an
681
+ // ordinary nil and is reported as an invalid data finding. A stray
682
+ // `]` took the throwing path instead and came back as a broken
683
+ // SCHEMA -- the same fault, classified two opposite ways by which
684
+ // branch the parser happened to take.
685
+ //
686
+ // The FIRST error only: the parser stops at the first syntax error,
687
+ // so a second entry would be a consequence of the first rather than
688
+ // a separate thing to fix.
689
+ const failure = dataCtx.err[0]
690
+ if (null == failure) {
691
+ return { verdict: 'error', truncated: false, findings: [] }
692
+ }
693
+ failure.site.url = dataUrl
694
+ materialise(failure, dataCtx)
695
+ return {
696
+ verdict: 'invalid',
697
+ truncated: false,
698
+ findings: [findingOf(failure, { data: new Set([dataUrl]) })],
699
+ }
700
+ }
701
+ // STAMP THE WHOLE SETTLED SCHEMA, not just the lifted anchor.
702
+ // Without `--at` these are the same tree. With it, the anchor is a
703
+ // subtree and the rest of the schema is still REACHABLE from inside
704
+ // it -- a `%alias` declaration (`[&: %U]`, target `$.%U`) or a
705
+ // recursive residual's `$.spec.Step`, both of which the meet
706
+ // resolves through _fixroot (RefVal.find, RecurseVal.body). A node
707
+ // reached that way but never stamped carries no url, so its site
708
+ // named no file while excerpting the schema's text -- against the
709
+ // invariant that every site names the file whose text it shows
710
+ // (finding F, §25). stampUrl only fills a url that is EMPTY, so
711
+ // stamping the superset never renames a value read from an include.
712
+ stampUrl(schemaVal, schemaUrl)
713
+ const dataUrls = stampUrl(dataVal, dataUrl)
714
+ // The projection every site in this report goes through: roles by
715
+ // url-set membership, names by how the caller reached each document.
716
+ const prov: Prov = {
717
+ data: dataUrls,
718
+ schemaUrl, schemaPath: options.schemaPath,
719
+ dataUrl, dataPath: options.dataPath,
720
+ }
721
+
722
+ // Default-validity lint (G3 phase 5, re-examined under ADR-004): for
723
+ // every disjunction in the SCHEMA carrying a preference, warn when
724
+ // the effective default is not an instance of any REMAINING
725
+ // alternative (code `pref_not_instance`, class compat, severity
726
+ // warning).
727
+ //
728
+ // What the finding MEANS changed with the admission gate (ADR-004).
729
+ // Before the gate it flagged a soundness hole: the preference held
730
+ // the disjunction open, so `a:*5|string` both generated a value the
731
+ // alternatives refuse AND admitted any same-kind override. The gate
732
+ // closed that hole — a preferred branch now contributes exactly its
733
+ // own value to the admitted set, so a default can no longer be
734
+ // "invalid against its own disjunct" and the enum-with-default idiom
735
+ // (`*'auto'|'literal'|'data'`) is sound as written. The lint is KEPT,
736
+ // as an advisory: a default admitted only because it is the default
737
+ // is also the exact shape of a typo'd default
738
+ // (`level:*wran|info|warn|debug` — the intended `*warn` would be
739
+ // silent), and nothing at meet time can catch that. The
740
+ // repeated-branch spelling (`*warn|warn|...`) states "the default is
741
+ // a first-class member", silences the lint, and — unlike before the
742
+ // gate — enforces exactly the same admitted set. The message names
743
+ // the REMAINING alternatives because that is what was scanned: the
744
+ // preferred branch itself always admits its own default, so the old
745
+ // wording ("any alternative of *5|string") read as false on its face
746
+ // (use-cases/BUGS.md §4).
747
+ const lintFindings: VetFinding[] = []
748
+ walkBagVals(anchor, (v: any, path: string[]): void => {
749
+ if (true === v.isDisjunct && Array.isArray(v.peg)) {
750
+ const d = effectiveDefault(v)
751
+ if (null != d && 'indeterminate' !== d) {
752
+ const rest = v.peg.filter((m: any) => true !== m?.isPref)
753
+ const state: any = {
754
+ profile: 'values', findings: [],
755
+ generalUrl: schemaUrl, specificUrl: schemaUrl,
756
+ }
757
+ const admitted = rest.some(
758
+ (m: any) => 'yes' === subsumeNode(state, path, m, d))
759
+ if (!admitted && 0 < rest.length) {
760
+ lintFindings.push({
761
+ code: 'pref_not_instance',
762
+ class: 'compat',
763
+ severity: 'warning',
764
+ path: pathText(path),
765
+ message: 'the default ' + d.canon +
766
+ ' is not an instance of any remaining alternative of ' +
767
+ v.canon,
768
+ sites: [{
769
+ file: schemaUrl,
770
+ row: d.site?.row ?? -1,
771
+ col: d.site?.col ?? -1,
772
+ len: d.site?.len ?? -1,
773
+ role: 'schema',
774
+ src: d.site?.src ?? '',
775
+ value: d.canon,
776
+ }],
777
+ })
778
+ }
779
+ }
780
+ }
781
+ })
782
+
783
+ // `--closed` sets the flag `close()` itself sets, rather than wrapping
784
+ // the anchor in a CloseFuncVal: the anchor is an already-evaluated
785
+ // tree, and a func value would have to resolve again to have any
786
+ // effect. A scalar anchor has no keys to close, so the flag is only
787
+ // meaningful on a bag.
788
+ if (true === options.closed && (true === anchor.isMap || true === anchor.isList)) {
789
+ anchor.closed = true
790
+ }
791
+
792
+ // THE MEET IS FROM A FRESH PARSE, NOT THE SETTLED SCHEMA (the
793
+ // review's finding C, use-cases/BUGS.md §15).
794
+ //
795
+ // Step 1 evaluated the schema ALONE, to decide whether it stands up
796
+ // before any data is blamed for it. That answer is a diagnosis, and
797
+ // it was also being used as the left side of the meet -- so every
798
+ // reference in the schema had already RESOLVED against the schema's
799
+ // own values and been replaced by them. `a:integer b:$.a` settled to
800
+ // `a:integer b:integer`, and data `{a:3,b:4}` then vetted VALID,
801
+ // while the same four lines as one document refuse with
802
+ // scalar_value. A reference is a statement about the FINAL model, and
803
+ // vet is asking about a model the data is part of.
804
+ //
805
+ // Parsing again is what makes `vet(S,D)` and `eval(S ∪ D)` the same
806
+ // question: the meet runs the fixpoint once, over both documents, so
807
+ // references, spreads and generators all see the data. Parsed trees
808
+ // are single-use, hence a second parse rather than a reuse of step
809
+ // 1's. The lint above still reads the SETTLED tree, where
810
+ // disjunctions are ranked and normalised.
811
+ //
812
+ // ONLY WHEN THERE IS NO `--at`. An anchor is a SUBTREE lifted out of
813
+ // the schema, and an absolute reference inside it (`$.OrderPlaced`,
814
+ // the discriminated-union idiom) names a sibling of the document
815
+ // root -- which the lifted subtree no longer has. The settled tree is
816
+ // where those references have already been resolved and substituted,
817
+ // so an anchored run keeps meeting that, exactly as it always has.
818
+ // Making the rule explicit rather than leaving it to whether
819
+ // anchorAt happens to find the path in an unresolved tree: the two
820
+ // ports answered that differently, which is an ADR-001 divergence
821
+ // waiting to happen.
822
+ const ctx = aontu.ctx({ collect: true })
823
+ let meetAnchor: any = anchor
824
+ if (null == options.at) {
825
+ const meetCtx = aontu.ctx({ collect: true })
826
+ const freshSchema: any = aontu.parse(schemaSrc, schemaOpts, meetCtx)
827
+ if (0 === meetCtx.err.length && null != freshSchema) {
828
+ meetAnchor = freshSchema
829
+ if (true === options.closed &&
830
+ (true === meetAnchor.isMap || true === meetAnchor.isList)) {
831
+ meetAnchor.closed = true
832
+ }
833
+ stampUrl(meetAnchor, schemaUrl)
834
+ }
835
+ }
836
+ else {
837
+ // A RECURSIVE residual inside the lifted anchor still names its
838
+ // definition by absolute path (`then?: $.spec.Step` -- the
839
+ // fixpoint, RECURSION.0.md), and the meet's root is the anchored
840
+ // subtree, which does not contain `$.spec`. Without a tree to
841
+ // walk, the residual held its peer forever and everything under a
842
+ // recursive field vetted VALID unchecked. The settled schema root
843
+ // is kept on the meet context for exactly that walk
844
+ // (AontuContext._fixroot; RecurseVal.body and RefVal.find fall
845
+ // back to it).
846
+ ; (ctx as any)._fixroot = schemaVal
847
+ // And the meet DRIVES AT the anchor's own path, so every finding
848
+ // minted live during it sits in the schema's namespace
849
+ // ($.spec.Step.then.approver), exactly where the findings carried
850
+ // on the settled anchor's stored paths already sit -- the Go port
851
+ // gets this for free from its clone discipline, and the shared
852
+ // anchored-vet rows pin the agreement.
853
+ ; (ctx as any).path = options.at.replace(/^\$\.?/, '')
854
+ .split('.').filter((s: string) => '' !== s)
855
+ }
856
+ const pair = new ConjunctVal({ peg: [meetAnchor, dataVal] }, ctx)
857
+ const unified: any = aontu.unify(pair, undefined, ctx)
858
+
859
+ // 4. Contradictions: every NilVal standing in the result, PLUS the
860
+ // ones that never made it into the tree.
861
+ //
862
+ // The second half is not belt-and-braces. When a parent collapses to
863
+ // a nil the whole subtree goes with it, so `service: close({...})`
864
+ // meeting a typo AND a kind conflict leaves ONE nil in the tree and
865
+ // reports the other only on the context — the vet verb's own
866
+ // motivating example, reporting half of what it found. The language
867
+ // server already walks both for this reason; vet dedups by identity
868
+ // the same way, and skips the transient disjunct-trial sentinel,
869
+ // which is bookkeeping rather than a finding.
870
+ const seen = new Set<any>()
871
+ const nils: any[] = collectNils(unified, seen)
872
+ for (const err of ctx.err) {
873
+ if (true === err?.isNil && '|:trial-nil' !== err.why && !seen.has(err)) {
874
+ seen.add(err)
875
+ nils.push(err)
876
+ }
877
+ }
878
+
879
+ const findings: VetFinding[] = nils.map((n) => {
880
+ materialise(n, ctx)
881
+ return findingOf(n, prov)
882
+ })
883
+
884
+ // 5. Incompleteness: what is left standing that cannot generate. The
885
+ // generate check runs in its own collect context so nothing it
886
+ // raises reaches the caller's error list, and so a schema that is
887
+ // merely unsatisfied does not look like one that is contradicted.
888
+ // No try/catch: in collect mode `gen` records its reasons on the
889
+ // context instead of throwing, which is the whole point of the mode.
890
+ const genCtx: any = aontu.ctx({ collect: true })
891
+ genCtx.root = unified
892
+ // Under `--at` the probe descends through the OUTPUT marks: the
893
+ // caller named this node as the truth to validate against, so a
894
+ // `type()` or `hide()` on it (or propagated into it) is not a reason
895
+ // to check nothing. See AontuContext.probe.
896
+ genCtx.probe = null != options.at
897
+ unified.gen(genCtx)
898
+ for (const err of genCtx.err) {
899
+ // A CONFLICT RAISED AT GENERATION COUNTS TOO (the review's finding
900
+ // C, use-cases/BUGS.md §16). The filter used to keep the
901
+ // `incomplete` class alone, on the reading that step 4 had already
902
+ // found every contradiction -- true while every conflict was
903
+ // decided during the meet, and untrue since a sizing atom or a
904
+ // container `must` may hold a PROVISIONAL reading until generation,
905
+ // which is where no more members can arrive. Dropping those left
906
+ // `vet` answering `valid` for data the evaluator refuses, which is
907
+ // the one disagreement the vet-equals-eval harness exists to catch
908
+ // -- and did.
909
+ //
910
+ // Deduped against step 4 by the same cause key the loop below uses,
911
+ // so a contradiction seen twice is still reported once.
912
+ if ('incomplete' === err.class || 'conflict' === err.class) {
913
+ materialise(err, genCtx)
914
+ findings.push(findingOf(err, prov))
915
+ }
916
+ }
917
+
918
+ // 5b. Deprecation warnings (G3 phase 4): a value that carries the
919
+ // deprecate() record after the meet was USED — the data met a
920
+ // deprecated schema value, or the schema's own default will
921
+ // generate one. Severity `warning` (the slot G2 reserved for
922
+ // exactly this mark), and warnings never touch the verdict below.
923
+ findings.push(...lintFindings)
924
+ for (const { val, path } of collectDeprecations(unified)) {
925
+ const v: any = val
926
+ // The same file/role projection sitesOf makes: the url as stamped
927
+ // (empty when the value belongs to neither document), the role by
928
+ // comparing it to the data document's.
929
+ const file = v.site.url
930
+ findings.push({
931
+ code: 'deprecated',
932
+ class: 'compat',
933
+ severity: 'warning',
934
+ path: pathText(path),
935
+ message: deprecationMessage(v.deprecation),
936
+ sites: [{
937
+ file: displayOf(file, roleOf(file, prov), prov),
938
+ row: v.site.row ?? -1,
939
+ col: v.site.col ?? -1,
940
+ len: v.site.len ?? -1,
941
+ role: roleOf(file, prov),
942
+ src: v.site.src ?? '',
943
+ value: v.canon,
944
+ }],
945
+ })
946
+ }
947
+
948
+ const keyed = findings.map((f, i) => ({ key: orderKey(f, i), finding: f }))
949
+ keyed.sort((a, b) => a.key < b.key ? -1 : 1)
950
+ let ordered = keyed.map((k) => k.finding)
951
+
952
+ // ONE CAUSE, ONE FINDING. A reference resolves by CLONING its target,
953
+ // so a target that later fails can fail once per referrer — same
954
+ // code, same two source sites, a different path each time. Multi-pass
955
+ // collection (G2 phase 6) made this reachable: the pass loop now
956
+ // continues past the erroring pass, so the clones' own folds run too.
957
+ // The dedup key is the CODE plus the SITES (file, row, col, value,
958
+ // role): two findings that name the same meet of the same two source
959
+ // positions are one contradiction observed from two paths. The key is
960
+ // NOT (code, path) — the design's sketch — because the paths are
961
+ // exactly what differ. Sorted order makes the kept finding the first
962
+ // by data site then path, deterministically in both ports.
963
+ //
964
+ // THE KEPT PATH IS THE DEEPEST one (use-cases/BUGS.md §41). A meet
965
+ // that fails inside a REFERENCED map is recorded twice: once at the
966
+ // key that actually conflicts, and once at the enclosing map, which
967
+ // collapsed as a consequence and carries the child's two sites. Both
968
+ // are the same cause; only the deeper one names the field an author
969
+ // or an agent has to edit, and `$.q` for a conflict in `$.q.a` sent a
970
+ // repair loop to rewrite the whole record -- twice over, identically,
971
+ // when two of its fields conflicted. Depth first, then the sort order
972
+ // above, so the choice stays deterministic in both ports.
973
+ const causeKey = (f: VetFinding): string =>
974
+ f.code + '\u0000' + f.sites.map((s) =>
975
+ [s.file, s.row, s.col, s.role, s.value].join('\u0000')).join('\u0000')
976
+ const depth = (f: VetFinding): number => f.path.split('.').length
977
+ const deepest = new Map<string, VetFinding>()
978
+ for (const f of ordered) {
979
+ const cause = causeKey(f)
980
+ const held = deepest.get(cause)
981
+ if (null == held || depth(held) < depth(f)) {
982
+ deepest.set(cause, f)
983
+ }
984
+ }
985
+ const causes = new Set<string>()
986
+ ordered = ordered.filter((f) => {
987
+ const cause = causeKey(f)
988
+ if (causes.has(cause) || deepest.get(cause) !== f) {
989
+ return false
990
+ }
991
+ causes.add(cause)
992
+ return true
993
+ })
994
+
995
+ const truncated = maxErrors < ordered.length
996
+ const kept = truncated ? ordered.slice(0, maxErrors) : ordered
997
+
998
+ // 6. The verdict derives from finding CLASSES, never from codes, so a
999
+ // new code can never change exit behaviour.
1000
+ //
1001
+ // BY CLASS, NOT BY STAGE. The split used to be positional -- whatever
1002
+ // step 4 found counted as contradiction and whatever step 5 added
1003
+ // counted as incompleteness -- which stopped being true when a sizing
1004
+ // atom or a container `must` began holding a provisional reading
1005
+ // until generation (the review's finding C, use-cases/BUGS.md §16). A
1006
+ // CONTRADICTION found at generation is still a contradiction: reading
1007
+ // it as mere incompleteness answered `incomplete` where the evaluator
1008
+ // refuses, and `vet` and `eval` have to agree.
1009
+ //
1010
+ // So: an error-severity finding that is not INCOMPLETENESS makes the
1011
+ // document invalid, wherever it was found -- a contradiction, a parse
1012
+ // refusal, an unresolvable reference alike. Warnings (the `compat`
1013
+ // class: lint and deprecation) never touch the verdict.
1014
+ let verdict: VetVerdict = 'valid'
1015
+ const errors = ordered.filter((f) => 'error' === f.severity)
1016
+ const unmet = errors.filter((f) => 'incomplete' === f.class).length
1017
+ if (unmet < errors.length) {
1018
+ verdict = 'invalid'
1019
+ }
1020
+ else if (0 < unmet && true !== options.partial) {
1021
+ verdict = 'incomplete'
1022
+ }
1023
+
1024
+ return { verdict, truncated, findings: kept }
1025
+ }