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/cli.ts CHANGED
@@ -9,30 +9,265 @@
9
9
  // file and piped input, the source is read from stdin. See HELP below.
10
10
 
11
11
  // Named imports, not `import * as`: the namespace form makes tsc emit the
12
+ import { evalFailure } from './query'
12
13
  // __importStar downlevel helper, whose branches no supported Node takes.
13
- import { readFileSync } from 'node:fs'
14
- import { join, resolve } from 'node:path'
14
+ import {
15
+ mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync,
16
+ } from 'node:fs'
17
+ import { basename, dirname, join, resolve } from 'node:path'
18
+ import { tmpdir } from 'node:os'
15
19
  import { createInterface } from 'node:readline'
16
20
 
17
- import { Aontu, AontuError, exactJSON } from './aontu'
21
+ import {
22
+ Aontu, AontuError, setColor,
23
+ exactJSON, vet, subsume, trimCheck, relationCheck,
24
+ hcanon, canonHash,
25
+ get, why, patch, agentsMd,
26
+ } from './aontu'
27
+ import { sarifReport } from './report-sarif'
28
+ import { jsonSchema } from './jsonschema'
29
+ import { modTidy, modVerify, modVendor, modManifest } from './mod-tool'
30
+ import type {
31
+ ModTidyReport, ModVerifyReport, ModVendorReport, ModManifestReport,
32
+ } from './mod-tool'
33
+ import { modCacheDir } from './mod'
34
+ import { VET_MAX_ERRORS } from './vet'
35
+ import type { VetReport, VetFinding, VetVerdict } from './vet'
36
+ import type {
37
+ SubsumeReport, SubsumeVerdict, SubsumeProfile,
38
+ } from './subsume'
39
+ import type { TrimReport, TrimVerdict } from './trim'
40
+ import type { RelationReport, RelationVerdict } from './relation'
41
+ import { reachCheck } from './reach'
42
+ import type { ReachReport, ReachVerdict } from './reach'
43
+ import { view, viewSet } from './view'
44
+ import type {
45
+ ViewEdges, ViewFigure, ViewKind, ViewLoss, ViewOptions, ViewProfile,
46
+ ViewReport, ViewSetReport, ViewVerdict,
47
+ } from './view'
48
+ import type { QueryView } from './query'
49
+ import type { WhyRecord } from './provenance'
50
+ import { agentsMdSplice } from './agentsmd'
18
51
 
19
52
 
20
53
  type Mode = 'json' | 'canon'
21
54
 
22
55
 
23
56
  const HELP = `Usage: aontu [options] [file]
57
+ aontu vet [options] <schema> <data> [more-data...]
58
+ aontu subsume [options] <general> <specific>
59
+ aontu breaking --against <file|git#rev> [options] <file>
60
+ aontu trim --check [options] <file>
61
+ aontu relations [options] <file>
62
+ aontu reaches <from> <to> [--relation <name>] [options] <file>
63
+ aontu view <kind> [options] <file>...
64
+ aontu view --views <path> [--check] [options] <file>
65
+ aontu jsonschema [--at <path>] [--strict] [options] <file>
66
+ aontu hash [options] <file>
67
+ aontu mod tidy|verify|vendor|manifest [options] [dir]
68
+ aontu get <path> [options] <file>
69
+ aontu why <path> [options] <file>
70
+ aontu set <path>=<value>... --entry <file> --overlay <file>
71
+ aontu agentsmd [--write <AGENTS.md>] <file>
24
72
 
25
73
  Evaluate an Aontu source file and print the result as JSON.
26
74
  With no file on an interactive terminal, start a REPL.
27
75
  With no file and piped input, read the source from stdin.
28
76
 
77
+ The vet verb validates data documents against a schema document and
78
+ reports what does not hold, as text or as a machine-readable object.
79
+
80
+ The subsume verb asks whether every instance the specific document
81
+ admits, the general document admits too. The breaking verb runs that
82
+ query between a document and its own earlier versions.
83
+
29
84
  Options:
30
85
  -c, --canon Print the canonical form instead of generated JSON
31
86
  -h, --help Show this help and exit
87
+ --jsonl REPL: answer every command as one JSON line
32
88
  -v, --version Print the version and exit
89
+ --trust <t> Include capability: system (default), none, or
90
+ root[:dir] to confine @"..." below a directory.
91
+ Every verb takes it too, and a bare root means the
92
+ document's own directory
93
+ --include-root <dir> Shorthand for --trust root:<dir>
94
+
95
+ Mod options:
96
+ --format <f> text (default) or json
97
+ --against <dir> manifest: a prior version's module tree, to gate on
98
+
99
+ Mod subcommands:
100
+ tidy Resolve the module closure by minimum version selection and
101
+ rewrite mod-lock.aon in canonical form
102
+ verify Check every locked module still means what mod-lock.aon
103
+ pins, and change nothing (the CI gate; tidy rewrites)
104
+ vendor Materialise the locked closure into aon_vendor/
105
+ manifest Print the OCI artifact a publish would push, gated on the
106
+ breaking check against --against
107
+
108
+ Vet options:
109
+ --at <path> Validate against this path of the schema ($.a.b)
110
+ --closed Refuse keys the anchor does not declare
111
+ --partial Residue is reported but does not fail the run
112
+ --max-errors <n> Cap the finding list (default 20)
113
+ --format <f> text (default), json or sarif
114
+ --watch Re-run whenever a watched file changes
115
+
116
+ Vet exit codes:
117
+ 0 valid data unifies, and is concrete (or --partial)
118
+ 1 invalid at least one contradiction
119
+ 2 usage bad option, or a file that cannot be read
120
+ 3 incomplete no contradiction, but the truth is not yet satisfied
121
+ 4 error the schema is unusable on its own
122
+
123
+ Subsume options:
124
+ --profile <p> values, defaults (default) or gen
125
+ --at <path> Compare at this path of both documents ($.a.b)
126
+ --format <f> text (default) or json
127
+
128
+ Subsume exit codes:
129
+ 0 subsumes every specific instance is admitted
130
+ 1 does_not_subsume a witness exists (see the findings)
131
+ 2 usage bad option, or a file that cannot be read
132
+ 3 undecided no rule decides (a sub_* reason is reported)
133
+ 4 error a document does not stand up on its own
134
+
135
+ Breaking options:
136
+ --against <v> An earlier version: a file path, or git#<rev>
137
+ (resolved by 'git show'); repeatable
138
+ --at <path> Compare this path of both versions ($.a.b), so a
139
+ module's own version string and policy block do
140
+ not decide the verdict
141
+ --mode <m> backward (new admits old, the default), forward
142
+ (old admits new), or full (both); overrides the
143
+ document's own $.aontu_policy.compat declaration
144
+ --allow-undecided Exit 0 on undecided (the report still says so)
145
+ --allow-deprecated-removal
146
+ A finding about a value the old version already
147
+ deprecated warns instead of breaking
148
+ --format <f> text (default) or json
149
+
150
+ Breaking exit codes mirror subsume's: 0 compatible, 1 breaking,
151
+ 2 usage, 3 undecided, 4 error.
152
+
153
+ Trim options:
154
+ --check Report redundant entries as paths (required: trim
155
+ only reports for now; rewriting is a future editor)
156
+ --format <f> text (default) or json
157
+
158
+ Trim exit codes: 0 nothing redundant, 1 redundancies reported,
159
+ 2 usage, 4 the document does not stand up on its own.
160
+
161
+ Hash options:
162
+ --form Print the hash FORM (the hashed text) instead of the
163
+ hash, which is what to diff when a pin moves
164
+ --format <f> text (default) or json
165
+
166
+ Hash exit codes: 0 hashed, 2 usage, 4 the document does not stand up
167
+ on its own.
168
+
169
+ Get options:
170
+ -c, --canon Canonical-form fragment (default: generated JSON)
171
+ --keys Keys at the node, one per line
172
+ --types Shape view: concrete leaves lifted to their kinds
173
+ --depth <n> Structure to depth n; deeper nodes render as top
174
+ --format <f> text (default) or json
175
+
176
+ Get exit codes: 0 rendered, 1 the path names nothing, 2 usage, 4 the
177
+ document does not stand up on its own.
178
+
179
+ Why options:
180
+ --format <f> text (default) or json
181
+
182
+ Why exit codes mirror get's: 0 explained, 1 the path names nothing,
183
+ 2 usage, 4 the document does not stand up on its own.
184
+
185
+ View kinds: tree, matrix, graph, layer, sets, layers, ladder, poset
186
+ (the poset takes several files). The figure goes to stdout, the loss
187
+ report to stderr. With --views it draws every figure a document
188
+ declares as data, from one evaluation: each declaration names its own
189
+ kind and out file, nothing is written unless every figure rendered,
190
+ and --check gates the committed set.
191
+
192
+ View options:
193
+ --as <profile> text | mermaid | dot | er | svg, per kind: tree,
194
+ matrix, sets and layers draw text (default) or
195
+ svg; graph draws mermaid (default), dot or er;
196
+ layer draws text (default), mermaid or svg; ladder
197
+ and poset draw mermaid (default) or dot
198
+ --at <path> Restrict the figure to nodes under this path; the
199
+ path the ladder draws; where the poset compares
200
+ --views <path> Draw every figure the document declares at this
201
+ path, one evaluation, all or nothing; each
202
+ declaration names its own kind and out file
203
+ -o, --out <file> Write the figure here instead of stdout
204
+ --check Exit 1 if --out differs from what would be drawn;
205
+ nothing is written
206
+ --strict Exit 1 when the loss report holds anything beyond
207
+ edges_deduped, inverse_suppressed and crossings
208
+ --max-rows <n> Refuse a figure above this many rows (default 60)
209
+ --format <f> text (default) or json, the whole report
210
+ --relation <n> tree, matrix, layer: draw over this relation only;
211
+ graph: keep this predicate (repeatable)
212
+ --root <path> tree: draw only the subtree under this node;
213
+ repeatable
214
+ --order <o> matrix: canon (default) or partition
215
+ --closure matrix: mark transitively reachable cells +
216
+ --group-by <k> graph: one subgraph per distinct value of field k;
217
+ layer: one band per value (required)
218
+ --layers <a,b> layer: the bands in this order, top first; without
219
+ it the order is derived from the relation
220
+ --edges <e> layer: which of the relation's edges to draw over
221
+ the bands -- upward (the violations, the default
222
+ for text and svg), all (mermaid's default) or none
223
+ --label <k> graph: label each node with field k
224
+ --sets <path> sets: the map whose keys are the sets
225
+ --member <k> sets: the field holding each set's members
226
+ --universe <p> sets: the full element domain, so the empty
227
+ column exists
228
+ --min-degree <n> sets: drop intersections below this degree
229
+ --min-size <n> layers: drop intersections below this many paths
230
+ --max-cols <n> sets, layers: elide columns beyond this many
231
+ --profile <p> poset: values | defaults (default) | gen
232
+
233
+ View exit codes: 0 rendered, 1 --check mismatch or lossy under
234
+ --strict, 2 usage or --max-rows exceeded, 4 the document does not stand
235
+ up on its own, or a relation, root or path that names nothing.
236
+
237
+ Set options:
238
+ --entry <file> The document the change is checked against
239
+ --overlay <file> The file the change is appended to (created if
240
+ absent; not written when the change does not hold)
241
+ --in-place Rewrite a pinned literal where it was written,
242
+ instead of appending a line that contradicts it.
243
+ The span is verified against the source text
244
+ before writing, and where the value is not a
245
+ single editable literal in this overlay the
246
+ assignment is appended as usual with a warning
247
+ saying why
248
+ --dry-run Print the overlay that would be written, write
249
+ nothing
250
+ --format <f> text (default) or json
251
+
252
+ Set exit codes are vet's verdict classes: 0 valid, 1 invalid (the
253
+ change contradicts a pinned value -- aontu why locates it, and
254
+ --in-place rewrites it), 2 usage, 3 incomplete, 4 the entry does not
255
+ stand up on its own.
256
+
257
+ Agentsmd options:
258
+ --write <file> Splice the stanza into this file between the
259
+ aontu:begin and aontu:end markers, appending them
260
+ when they are absent; the rest is left alone
261
+
262
+ Agentsmd exit codes: 0 generated, 2 usage, 4 the document does not
263
+ stand up on its own.
33
264
 
34
265
  REPL commands:
35
266
  :help Show REPL help
267
+ :load <file> Evaluate a document and hold it for the commands below
268
+ :get [path] What the held document says at a path
269
+ :keys [path] The keys at a path of the held document
270
+ :why <path> Every contribution to the value at a path
36
271
  :canon Switch to canonical-form output
37
272
  :json Switch to JSON output
38
273
  :quit, :exit Exit the REPL (or press Ctrl-D)
@@ -78,7 +313,131 @@ function evalSource(
78
313
  }
79
314
 
80
315
 
81
- function runFile(file: string, mode: Mode): number {
316
+ // The include capability the main verb runs with (G5, docs/trust.md).
317
+ // `--trust` and `--include-root` set it explicitly; the default is
318
+ // 'system' WITH the warning window: every resolution that escapes the
319
+ // entry root or goes through package resolution prints a one-line
320
+ // stderr warning naming the flag a future default will require
321
+ // (phase 6, the staged flip).
322
+ type TrustArg =
323
+ | { kind: 'system-warn' }
324
+ | { kind: 'system' }
325
+ | { kind: 'none' }
326
+ | { kind: 'root', dir?: string }
327
+
328
+
329
+ // The one-line warning of the staged default flip. Once per (kind,
330
+ // path): a fixpoint re-resolves nothing (includes load at parse), but
331
+ // several includes may escape and each deserves exactly one line.
332
+ function makeTrustWarn(): (kind: 'escape' | 'pkg', path: string) => void {
333
+ const warned = new Set<string>()
334
+ return (kind, path) => {
335
+ const key = kind + ' ' + path
336
+ if (warned.has(key)) {
337
+ return
338
+ }
339
+ warned.add(key)
340
+ const how = 'pkg' === kind
341
+ ? 'through package resolution'
342
+ : 'outside the entry root'
343
+ process.stderr.write(
344
+ `aontu: warning: include resolved ${how}: ${path}` +
345
+ ` (a future release will deny this by default;` +
346
+ ` pass --trust system to keep it, or --include-root to confine)\n`)
347
+ }
348
+ }
349
+
350
+
351
+ // Build the evaluator options a TrustArg means, for an entry rooted at
352
+ // entryRoot (the entry file's directory, or the working directory for
353
+ // stdin/REPL).
354
+ function trustOpts(trust: TrustArg, entryRoot: string): any {
355
+ switch (trust.kind) {
356
+ case 'none':
357
+ return { trust: { include: 'none' } }
358
+ case 'root':
359
+ return { trust: { include: { root: trust.dir ?? entryRoot } } }
360
+ case 'system':
361
+ return {}
362
+ default: // system-warn: today's default plus the warning window
363
+ return { trustWarn: makeTrustWarn(), trustWarnRoot: entryRoot }
364
+ }
365
+ }
366
+
367
+
368
+ // EVERY VERB honours the include capability, not just the bare
369
+ // command. G5 wired `--trust`/`--include-root` to `aontu <file>` alone,
370
+ // so `aontu vet schema.aon data.json` -- the surface an agent actually
371
+ // scripts -- ran the full system resolver with no flag to confine it
372
+ // and no warning (use-cases/REVIEW.md finding G). The flags are
373
+ // stripped here, before each verb parses its own tail, so a verb only
374
+ // has to pass the profile on to its engine.
375
+ //
376
+ // Returns undefined when the spelling is wrong, with the message
377
+ // already printed: the caller answers the usage class.
378
+ function takeTrust(argv: string[]):
379
+ { argv: string[], trust: TrustArg } | undefined {
380
+ const rest: string[] = []
381
+ let trust: TrustArg = { kind: 'system-warn' }
382
+ for (let i = 0; i < argv.length; i++) {
383
+ const arg = argv[i]
384
+ if ('--trust' === arg) {
385
+ const parsed = null == argv[i + 1] ? undefined : parseTrustArg(argv[++i])
386
+ if (null == parsed) {
387
+ process.stderr.write(
388
+ 'aontu: --trust needs system, none, or root[:dir]\n')
389
+ return undefined
390
+ }
391
+ trust = parsed
392
+ }
393
+ else if ('--include-root' === arg) {
394
+ const dir = argv[++i]
395
+ if (null == dir) {
396
+ process.stderr.write('aontu: --include-root needs a directory\n')
397
+ return undefined
398
+ }
399
+ trust = { kind: 'root', dir }
400
+ }
401
+ else {
402
+ rest.push(arg)
403
+ }
404
+ }
405
+ return { argv: rest, trust }
406
+ }
407
+
408
+
409
+ // The evaluator options a REPL session's capability means.
410
+ function replTrust(state: ReplState, entryRoot: string): any {
411
+ const capability = verbTrust(
412
+ state.trust ?? { kind: 'system-warn' }, entryRoot)
413
+ return null == capability ? {} : { trust: capability }
414
+ }
415
+
416
+
417
+ // The capability a verb's engine runs under. `system` and the staged
418
+ // warning default both mean today's behaviour (no option); the warning
419
+ // window itself stays a bare-command nicety, because a verb's report
420
+ // is a machine contract and a stderr line is not part of it.
421
+ function verbTrust(trust: TrustArg, entryRoot: string): any {
422
+ switch (trust.kind) {
423
+ case 'none':
424
+ return { include: 'none' }
425
+ case 'root':
426
+ return { include: { root: trust.dir ?? entryRoot } }
427
+ default:
428
+ return undefined
429
+ }
430
+ }
431
+
432
+
433
+ // The directory a bare `--trust root` confines to for a verb: the
434
+ // primary document's own, matching the bare command's entry root.
435
+ function entryRootOf(file: string | undefined): string {
436
+ return null == file ? process.cwd() : dirname(resolve(file))
437
+ }
438
+
439
+
440
+ function runFile(file: string, mode: Mode, trust: TrustArg): number {
82
441
  let src: string
83
442
  try {
84
443
  src = readFileSync(file, 'utf8')
@@ -88,20 +447,22 @@ function runFile(file: string, mode: Mode): number {
88
447
  return 1
89
448
  }
90
449
 
91
- const aontu = new Aontu({ path: resolve(file) })
450
+ const path = resolve(file)
451
+ const aontu = new Aontu({ path, ...trustOpts(trust, dirname(path)) })
92
452
  const res = evalSource(aontu, src, mode)
93
453
  ;(res.ok ? process.stdout : process.stderr).write(res.text + '\n')
94
454
  return res.ok ? 0 : 1
95
455
  }
96
456
 
97
457
 
98
- function runStdin(mode: Mode): Promise<number> {
458
+ function runStdin(mode: Mode, trust: TrustArg): Promise<number> {
99
459
  return new Promise((resolve) => {
100
460
  let src = ''
101
461
  process.stdin.setEncoding('utf8')
102
462
  process.stdin.on('data', (d) => (src += d))
103
463
  process.stdin.on('end', () => {
104
- const res = evalSource(new Aontu(), src, mode)
464
+ const res = evalSource(
465
+ new Aontu(trustOpts(trust, process.cwd())), src, mode)
105
466
  ;(res.ok ? process.stdout : process.stderr).write(res.text + '\n')
106
467
  resolve(res.ok ? 0 : 1)
107
468
  })
@@ -109,46 +470,193 @@ function runStdin(mode: Mode): Promise<number> {
109
470
  }
110
471
 
111
472
 
112
- function runRepl(initialMode: Mode): void {
113
- let mode = initialMode
114
- const aontu = new Aontu()
473
+ // THE REPL AS AN INSPECTION TOOL (G7 phase 7): `:load` holds a
474
+ // document, and `:get`, `:keys` and `:why` ask the query and
475
+ // provenance surfaces about it, so the session is a place to
476
+ // INTERROGATE a definition rather than only to evaluate snippets.
477
+ //
478
+ // The command handler is a PURE FUNCTION of (state, line): a readline
479
+ // loop is untestable, and every answer this REPL gives has to be as
480
+ // checkable as the CLI's. File reading is injected for the same
481
+ // reason.
482
+ export type ReplState = {
483
+ // How a value renders: the `:canon` / `:json` toggle.
484
+ mode: Mode
485
+ // The SESSION protocol: one JSON line per answer, for a harness
486
+ // driving the REPL. Human-readable output stays the default.
487
+ jsonl: boolean
488
+ name?: string
489
+ src?: string
490
+ // The include capability the session evaluates under. `--trust` and
491
+ // `--include-root` were parsed and then DROPPED on the way to the
492
+ // REPL, so `--jsonl` -- the surface built to be driven by a harness
493
+ // -- ran unconfined however it was invoked (use-cases/REVIEW.md
494
+ // finding G). The state carries it, so every line honours it.
495
+ trust?: TrustArg
496
+ }
497
+
498
+ export type ReplAnswer = {
499
+ close: boolean
500
+ out: string
501
+ state: ReplState
502
+ }
503
+
504
+
505
+ // The loaded document, or the answer to give when there is none.
506
+ function replLoaded(state: ReplState): string | undefined {
507
+ return state.src
508
+ }
509
+
510
+
511
+ export function replCommand(
512
+ state: ReplState,
513
+ line: string,
514
+ read: (file: string) => string,
515
+ ): ReplAnswer {
516
+ const s = line.trim()
517
+ const answer = (out: string, next?: Partial<ReplState>): ReplAnswer => {
518
+ const st = { ...state, ...(next ?? {}) }
519
+ return {
520
+ close: false,
521
+ out: st.jsonl ? exactJSON({ ok: true, out }) : out,
522
+ state: st,
523
+ }
524
+ }
525
+ const refuse = (out: string): ReplAnswer => ({
526
+ close: false,
527
+ out: state.jsonl ? exactJSON({ ok: false, out }) : out,
528
+ state,
529
+ })
530
+
531
+ if ('' === s) {
532
+ return { close: false, out: '', state }
533
+ }
534
+
535
+ if (!s.startsWith(':')) {
536
+ const res = evalSource(
537
+ new Aontu(replTrust(state, process.cwd())), s, state.mode)
538
+ return res.ok ? answer(res.text) : refuse(res.text)
539
+ }
540
+
541
+ const sp = s.indexOf(' ')
542
+ const cmd = sp < 0 ? s : s.slice(0, sp)
543
+ const arg = sp < 0 ? '' : s.slice(sp + 1).trim()
544
+
545
+ switch (cmd) {
546
+ case ':help':
547
+ // Trimmed: the loop adds the newline, and the Go REPL answers
548
+ // the same string — a help text that differed by a blank line
549
+ // between the ports would be a parity diff in the one output
550
+ // every user sees first.
551
+ return answer(HELP.replace(/\n$/, ''))
552
+
553
+ case ':canon':
554
+ return answer('canon output', { mode: 'canon' })
555
+
556
+ case ':json':
557
+ return answer('json output', { mode: 'json' })
558
+
559
+ case ':quit':
560
+ case ':exit':
561
+ return { close: true, out: '', state }
562
+
563
+ case ':load': {
564
+ if ('' === arg) {
565
+ return refuse(':load needs a file')
566
+ }
567
+ let src: string
568
+ try {
569
+ src = read(arg)
570
+ }
571
+ catch (err: any) {
572
+ return refuse(`cannot read ${arg}: ${err.message}`)
573
+ }
574
+ // Evaluated ONCE, and what is held is the source: parsed trees
575
+ // are single-use, so every later question re-evaluates from the
576
+ // text rather than reusing a tree that has already been spent.
577
+ const res = evalSource(
578
+ new Aontu({ path: arg, ...replTrust(state, dirname(resolve(arg))) }),
579
+ src, state.mode)
580
+ return res.ok
581
+ ? answer(`loaded: ${arg}\n${res.text}`, { name: arg, src })
582
+ : refuse(res.text)
583
+ }
584
+
585
+ case ':get':
586
+ case ':keys':
587
+ case ':why': {
588
+ const src = replLoaded(state)
589
+ if (null == src) {
590
+ return refuse('nothing loaded (try :load <file>)')
591
+ }
592
+ const path = '' === arg ? '$' : arg
593
+ if (':why' === cmd) {
594
+ const report = why(src, path, {
595
+ path: state.name,
596
+ trust: verbTrust(state.trust ?? { kind: 'system-warn' },
597
+ entryRootOf(state.name)),
598
+ })
599
+ return report.ok
600
+ ? answer(renderWhyText(report.record as WhyRecord))
601
+ : refuse(report.findings.map(renderFinding).join('\n'))
602
+ }
603
+ const view: QueryView = ':keys' === cmd
604
+ ? 'keys' : 'canon' === state.mode ? 'canon' : 'json'
605
+ const report = get(src, path, {
606
+ view, path: state.name,
607
+ trust: verbTrust(state.trust ?? { kind: 'system-warn' },
608
+ entryRootOf(state.name)),
609
+ })
610
+ return report.ok
611
+ ? answer(report.out)
612
+ : refuse(report.findings.map(renderFinding).join('\n'))
613
+ }
614
+
615
+ default:
616
+ return refuse(`unknown command: ${s} (try :help)`)
617
+ }
618
+ }
619
+
620
+
621
+ function runRepl(initialMode: Mode, jsonl: boolean, trust: TrustArg): void {
622
+ let state: ReplState = { mode: initialMode, jsonl, trust }
115
623
  const rl = createInterface({
116
624
  input: process.stdin,
117
625
  output: process.stdout,
118
- prompt: 'aontu> ',
626
+ prompt: jsonl ? '' : 'aontu> ',
119
627
  })
120
628
 
121
- process.stdout.write(
122
- `Aontu v${version()} REPL — :help for commands, :quit to exit\n`)
629
+ if (!jsonl) {
630
+ process.stdout.write(
631
+ `Aontu v${version()} REPL — :help for commands, :quit to exit\n`)
632
+ }
123
633
  rl.prompt()
124
634
 
125
635
  rl.on('line', (line) => {
126
- const s = line.trim()
127
-
128
- if ('' === s) {
129
- rl.prompt()
636
+ const res = replCommand(state, line, (f) => readFileSync(f, 'utf8'))
637
+ state = res.state
638
+ if (res.close) {
639
+ rl.close()
130
640
  return
131
641
  }
132
-
133
- if (s.startsWith(':')) {
134
- switch (s) {
135
- case ':help': process.stdout.write(HELP); break
136
- case ':canon': mode = 'canon'; process.stdout.write('canon output\n'); break
137
- case ':json': mode = 'json'; process.stdout.write('json output\n'); break
138
- case ':quit': case ':exit': rl.close(); return
139
- default: process.stdout.write(`unknown command: ${s} (try :help)\n`)
140
- }
141
- rl.prompt()
142
- return
642
+ if ('' !== res.out) {
643
+ process.stdout.write(res.out + '\n')
143
644
  }
144
-
145
- const res = evalSource(aontu, s, mode)
146
- process.stdout.write(res.text + '\n')
147
645
  rl.prompt()
148
646
  })
149
647
 
150
648
  rl.on('close', () => {
151
- process.stdout.write('\n')
649
+ // The closing newline is for a HUMAN, so it is written only for
650
+ // one: it moves the terminal off the prompt line that `rl` left
651
+ // hanging. In `--jsonl` there is no prompt, every answer already
652
+ // ends in its own newline, and this one appended a bare empty line
653
+ // to the stream -- a record that is not JSON, at the end of a
654
+ // protocol whose whole contract is one JSON object per line. A
655
+ // harness parsing every line it receives failed on it, after the
656
+ // commands had all succeeded. Mirrors go/cmd/aontu/repl.go.
657
+ if (!jsonl) {
658
+ process.stdout.write('\n')
659
+ }
152
660
  // Same reason as finish(): the REPL requires a TTY stdin, but stdout
153
661
  // can still be a pipe (`aontu | cat`), so exiting outright could
154
662
  // discard queued output here too.
@@ -157,64 +665,2808 @@ function runRepl(initialMode: Mode): void {
157
665
  }
158
666
 
159
667
 
160
- // Exit without truncating output.
161
- //
162
- // process.exit() terminates immediately, discarding anything still
163
- // queued on stdout. A write to a PIPE is asynchronous once it exceeds
164
- // the pipe buffer, so `write(big); exit(0)` silently truncated output at
165
- // 65536 bytes — while a write to a TTY or a file, being synchronous,
166
- // looked fine. Setting exitCode instead lets the process end naturally,
167
- // after the queue drains.
668
+
669
+ // THE VET VERB (G2 phase 3).
168
670
  //
169
- // This predates the exact leaves but they make it trivially reachable
170
- // (one long biginteger canon exceeds the buffer), and it lands squarely
171
- // on the parity-probe discipline in AGENTS.md, which derives expected
172
- // spec values by piping BOTH CLIs and comparing. A truncated pipe there
173
- // reads as a port divergence.
174
- function finish(code: number): void {
175
- process.exitCode = code
671
+ // Exit codes are VERDICT CLASSES, not a pass/fail bit: an agent loop
672
+ // branches on "the data contradicts the truth" (1) differently from
673
+ // "the data has not supplied everything the truth requires" (3), and
674
+ // differently again from "the schema itself is broken" (4), which is
675
+ // never the data's fault. 2 stays what it already was for this CLI --
676
+ // the caller got the invocation wrong -- which is why an unreadable
677
+ // file is a 2 rather than a 4.
678
+ const VET_EXIT: Record<VetVerdict, number> = {
679
+ valid: 0,
680
+ invalid: 1,
681
+ incomplete: 3,
682
+ error: 4,
176
683
  }
177
684
 
685
+ const VET_HELP = 'aontu vet <schema> <data> [more-data...] (try --help)'
178
686
 
179
- function main(argv: string[]): void {
180
- let mode: Mode = 'json'
181
- let file: string | undefined
182
687
 
183
- for (const arg of argv.slice(2)) {
184
- if ('-c' === arg || '--canon' === arg) {
185
- mode = 'canon'
688
+ type VetFormat = 'text' | 'json' | 'sarif'
689
+
690
+ type VetArgs = {
691
+ help?: boolean
692
+ schema: string
693
+ data: string[]
694
+ format: VetFormat
695
+ at?: string
696
+ closed?: boolean
697
+ partial?: boolean
698
+ maxErrors?: number
699
+ watch?: boolean
700
+ }
701
+
702
+
703
+ // Parse the verb's argv tail. Returns the error text instead of
704
+ // throwing, so the caller owns the exit code.
705
+ function parseVetArgs(argv: string[]): { args?: VetArgs; err?: string } {
706
+ const files: string[] = []
707
+ let format: VetFormat = 'text'
708
+ let at: string | undefined
709
+ let closed = false
710
+ let partial = false
711
+ let maxErrors: number | undefined
712
+ let watch = false
713
+
714
+ for (let i = 0; i < argv.length; i++) {
715
+ const arg = argv[i]
716
+
717
+ // `-h`/`--help` before anything else, INCLUDING the file count:
718
+ // the usage errors below all end with "(try --help)", and a verb
719
+ // that then refused --help as an unknown option was sending the
720
+ // reader in a circle.
721
+ if ('-h' === arg || '--help' === arg) {
722
+ return { args: { help: true, schema: '', data: [], format } }
186
723
  }
187
- else if ('-h' === arg || '--help' === arg) {
188
- process.stdout.write(HELP)
189
- return finish(0)
724
+
725
+ if ('--at' === arg) {
726
+ at = argv[++i]
727
+ if (null == at) {
728
+ return { err: 'aontu: --at needs a path' }
729
+ }
190
730
  }
191
- else if ('-v' === arg || '--version' === arg) {
192
- process.stdout.write(version() + '\n')
193
- return finish(0)
731
+ else if ('--format' === arg) {
732
+ const f = argv[++i]
733
+ if ('text' !== f && 'json' !== f && 'sarif' !== f) {
734
+ return { err: `aontu: --format needs text, json or sarif` }
735
+ }
736
+ format = f
737
+ }
738
+ else if ('--max-errors' === arg) {
739
+ // ONE GRAMMAR, spelled the same way in both ports: decimal
740
+ // digits, one to nine of them, at least 1. `Number()` alone
741
+ // accepted `1.0`, `1e2`, `0x10` and ` 3`, which Go's parser
742
+ // refuses -- so the same documented invocation meant different
743
+ // things in the two shipped commands. The nine-digit ceiling is
744
+ // where the ports would part company again: beyond it Go's
745
+ // integer conversion saturates, and a cap nobody can reach is
746
+ // not worth a divergence.
747
+ const raw = argv[++i]
748
+ if (!/^[0-9]{1,9}$/.test(raw ?? '') || 1 > Number(raw)) {
749
+ return { err: 'aontu: --max-errors needs a positive whole number' }
750
+ }
751
+ maxErrors = Number(raw)
752
+ }
753
+ else if ('--closed' === arg) {
754
+ closed = true
755
+ }
756
+ else if ('--partial' === arg) {
757
+ partial = true
758
+ }
759
+ else if ('--watch' === arg) {
760
+ watch = true
194
761
  }
195
762
  else if (arg.startsWith('-')) {
196
- process.stderr.write(`aontu: unknown option ${arg} (try --help)\n`)
197
- return finish(2)
763
+ return { err: `aontu: unknown vet option ${arg} (try --help)` }
198
764
  }
199
765
  else {
200
- file = arg
766
+ files.push(arg)
201
767
  }
202
768
  }
203
769
 
204
- if (null != file) {
205
- finish(runFile(file, mode))
770
+ if (files.length < 2) {
771
+ return { err: `aontu: vet needs a schema and at least one data file\n${VET_HELP}` }
206
772
  }
207
- else if (process.stdin.isTTY) {
208
- runRepl(mode)
773
+
774
+ return {
775
+ args: {
776
+ schema: files[0],
777
+ data: files.slice(1),
778
+ format,
779
+ at,
780
+ closed,
781
+ partial,
782
+ maxErrors,
783
+ watch,
784
+ },
209
785
  }
210
- else {
211
- runStdin(mode).then((code) => finish(code))
786
+ }
787
+
788
+
789
+ // One line per site, so a finding reads as "what is wrong, where the
790
+ // data says it, and where the truth says otherwise". The data site
791
+ // comes first because it is the one to edit.
792
+ function renderFinding(f: VetFinding): string {
793
+ const out: string[] = [`${f.path}: ${f.code} [${f.class}]`]
794
+
795
+ if ('' !== f.message) {
796
+ out.push(` ${f.message}`)
797
+ }
798
+ if (null != f.note) {
799
+ out.push(` note: ${f.note}`)
800
+ }
801
+ if (null != f.expected) {
802
+ out.push(` expected: ${f.expected}`)
803
+ }
804
+ if (null != f.actual) {
805
+ out.push(` actual: ${f.actual}`)
806
+ }
807
+ for (const s of f.sites) {
808
+ // Every site carries the canon of the value it stands for: that is
809
+ // what makes the two sides of a conflict readable side by side. A
810
+ // site's file is always a string -- empty when the value belongs to
811
+ // neither document -- so there is nothing to coalesce here.
812
+ out.push(` ${s.role}: ${s.file}:${s.row}:${s.col} (${s.value})`)
212
813
  }
213
- } /* node:coverage ignore next 8 */
214
814
 
815
+ return out.join('\n')
816
+ }
817
+
818
+
819
+ function renderVetText(report: VetReport): string {
820
+ const head = `verdict: ${report.verdict}` +
821
+ (report.truncated ? ' (findings truncated)' : '')
822
+
823
+ if (0 === report.findings.length) {
824
+ return head
825
+ }
826
+
827
+ return [head, ''].concat(report.findings.map(renderFinding)).join('\n')
828
+ }
829
+
830
+
831
+ // The machine-readable form. `aontu` names the producer, so a report
832
+ // read from a file or a pipe says which version and which verb made it
833
+ // without the consumer having to know.
834
+ function renderVetJson(report: VetReport): string {
835
+ return exactJSON({
836
+ aontu: { version: version(), verb: 'vet' },
837
+ verdict: report.verdict,
838
+ truncated: report.truncated,
839
+ findings: report.findings,
840
+ }, 2)
841
+ }
842
+
843
+
844
+ // The machine-interchange form (G2 phase 5): SARIF 2.1.0, rendered by
845
+ // the library (ts/src/report-sarif.ts) so an embedder gets the same
846
+ // bytes the CLI prints.
847
+ function renderVetSarif(report: VetReport): string {
848
+ return sarifReport(report, version())
849
+ }
850
+
851
+
852
+ // The worst verdict wins across data files: a run that is invalid
853
+ // anywhere is invalid, and a schema that cannot stand up makes every
854
+ // file's verdict moot.
855
+ const VET_RANK: Record<VetVerdict, number> = {
856
+ valid: 0,
857
+ incomplete: 1,
858
+ invalid: 2,
859
+ error: 3,
860
+ }
861
+
862
+
863
+ // One complete vet run: read every file, vet each data document, print
864
+ // one report, return the exit class. Split from runVet so `--watch` can
865
+ // repeat it — the files are re-read on every run, which is the point of
866
+ // watching them.
867
+ function vetOnce(args: VetArgs, trust: TrustArg): number {
868
+ let schemaSrc: string
869
+ const sources: { file: string; src: string }[] = []
870
+ try {
871
+ schemaSrc = readFileSync(args.schema, 'utf8')
872
+ for (const file of args.data) {
873
+ sources.push({ file, src: readFileSync(file, 'utf8') })
874
+ }
875
+ }
876
+ catch (err: any) {
877
+ process.stderr.write(`aontu: cannot read ${err.path}: ${err.message}\n`)
878
+ return 2
879
+ }
880
+
881
+ // Each data file is vetted on its own, because a parsed tree is
882
+ // single-use (docs/reference-api.md) -- and because two data files
883
+ // are two candidates for the same truth, not one merged candidate.
884
+ let verdict: VetVerdict = 'valid'
885
+ let truncated = false
886
+ const findings: VetFinding[] = []
887
+
888
+ for (const source of sources) {
889
+ const report = vet(schemaSrc, source.src, {
890
+ trust: verbTrust(trust, entryRootOf(args.schema)),
891
+ at: args.at,
892
+ closed: args.closed,
893
+ partial: args.partial,
894
+ maxErrors: args.maxErrors,
895
+ schemaUrl: args.schema,
896
+ dataUrl: source.file,
897
+ // The paths as well as the labels: a relative `@"file"` load
898
+ // inside either document resolves from ITS OWN directory, the
899
+ // way `aontu <file>` already resolves one (runFile above). The
900
+ // path is passed AS TYPED, not resolved: it doubles as the
901
+ // label above, and a report that mixed the typed path with an
902
+ // absolute one would name the same file two ways.
903
+ schemaPath: args.schema,
904
+ dataPath: source.file,
905
+ })
906
+
907
+ if (VET_RANK[verdict] < VET_RANK[report.verdict]) {
908
+ verdict = report.verdict
909
+ }
910
+ truncated = truncated || report.truncated
911
+ findings.push(...report.findings)
912
+
913
+ // A SCHEMA-SIDE FAULT IS THE SAME FAULT FOR EVERY DATA FILE, so it
914
+ // is reported ONCE. `error` means exactly that -- the run could not
915
+ // be set up from the truth's side, never the data's (the exit table
916
+ // in docs/reference-api.md) -- so the report the first file
917
+ // produced is the report every later file would produce, character
918
+ // for character. Concatenating them repeated one broken schema N
919
+ // times and, past the cap, marked the report `truncated` over a
920
+ // single underlying fault. It only became visible once the `error`
921
+ // verdict started carrying findings at all: while the list was
922
+ // empty there was nothing to duplicate.
923
+ if ('error' === report.verdict) {
924
+ break
925
+ }
926
+ }
927
+
928
+ // The cap is on the REPORT, not on each file. Capping every file's
929
+ // list and then concatenating them let `--max-errors 1` emit one
930
+ // finding PER FILE -- and leave `truncated` false while doing it,
931
+ // because no single file had been cut. The engine still caps each
932
+ // run, so a pathological file cannot flood the aggregate before it
933
+ // gets here; this is the second, honest cut.
934
+ const cap = args.maxErrors ?? VET_MAX_ERRORS
935
+ const kept = cap < findings.length ? findings.slice(0, cap) : findings
936
+
937
+ const report: VetReport = {
938
+ verdict,
939
+ truncated: truncated || cap < findings.length,
940
+ findings: kept,
941
+ }
942
+ const text = 'json' === args.format ? renderVetJson(report) :
943
+ 'sarif' === args.format ? renderVetSarif(report) :
944
+ renderVetText(report)
945
+
946
+ process.stdout.write(text + '\n')
947
+ return VET_EXIT[verdict]
948
+ }
215
949
 
216
- // No require.main guard here: bin/aontu.js is the executable entry and
217
- // calls main(process.argv) itself, so this module stays import-only.
218
950
 
951
+ // How often `--watch` polls for a change. Polling by mtime+size rather
952
+ // than fs.watch: the design asks for "re-run on file mtime change", and
953
+ // the native watcher's semantics differ by platform (rename versus
954
+ // change events, editors that replace the inode) in exactly the ways
955
+ // that made every build tool fall back to polling.
956
+ const WATCH_POLL_MS = 100
219
957
 
220
- export { evalSource, main }
958
+
959
+ function watchSignature(files: string[]): string {
960
+ return files.map((f) => {
961
+ // throwIfNoEntry, not try/catch: a file mid-save can be briefly
962
+ // absent, and "gone" is a state to notice, not an error to die on.
963
+ const stat = statSync(f, { throwIfNoEntry: false })
964
+ return null == stat ? 'gone' : `${stat.mtimeMs}:${stat.size}`
965
+ }).join('\n')
966
+ }
967
+
968
+
969
+ function sleep(ms: number): Promise<void> {
970
+ return new Promise((done) => setTimeout(done, ms))
971
+ }
972
+
973
+
974
+ // Resolve true when any watched file's signature moves off `before`.
975
+ // This is the real waiter: it never resolves false, so a real watch
976
+ // runs until the process is interrupted; tests inject their own waiter
977
+ // to bound the loop, and pass a short pollMs when they drive this one
978
+ // directly. The interval is a required argument (the command passes
979
+ // WATCH_POLL_MS) so there is no defaulting branch a test could never
980
+ // take.
981
+ //
982
+ // The BASELINE is an argument, not a snapshot taken here: the loop
983
+ // records it BEFORE each vet run, so a save landing between the run's
984
+ // reads and the wait still compares as a change. A waiter that
985
+ // snapshotted on entry would adopt that unvetted save as its baseline
986
+ // and wait indefinitely on a stale report.
987
+ async function watchChange(
988
+ files: string[], before: string, pollMs: number): Promise<boolean> {
989
+ for (;;) {
990
+ await sleep(pollMs)
991
+ if (watchSignature(files) !== before) {
992
+ return true
993
+ }
994
+ }
995
+ }
996
+
997
+
998
+ type VetWaiter = (files: string[], before: string) => Promise<boolean>
999
+
1000
+
1001
+ // The waiter the command runs with: the real change-poller at the real
1002
+ // interval. Named (rather than inlined at the runVet call) so the
1003
+ // production waiter itself is directly testable.
1004
+ const vetWaiter: VetWaiter = (files, before) =>
1005
+ watchChange(files, before, WATCH_POLL_MS)
1006
+
1007
+
1008
+ // The watch loop: one report per run, one run per change, streaming to
1009
+ // stdout. An unreadable file mid-watch reports (exit class 2 from
1010
+ // vetOnce) and keeps watching — a file being rewritten is briefly
1011
+ // unreadable, and dying on it would make the mode useless for the very
1012
+ // moment it exists for.
1013
+ async function watchVet(
1014
+ args: VetArgs, wait: VetWaiter, trust: TrustArg): Promise<number> {
1015
+ const files = [args.schema, ...args.data]
1016
+ let before = watchSignature(files)
1017
+ let code = vetOnce(args, trust)
1018
+ while (await wait(files, before)) {
1019
+ before = watchSignature(files)
1020
+ code = vetOnce(args, trust)
1021
+ }
1022
+ return code
1023
+ }
1024
+
1025
+
1026
+ // The vet verb. Non-watch runs are synchronous and return the exit
1027
+ // class directly; `--watch` returns a promise that resolves only when
1028
+ // the waiter says stop (never, for the real one).
1029
+ function runVet(argv: string[], wait?: VetWaiter): number | Promise<number> {
1030
+ const trusted = takeTrust(argv)
1031
+ if (null == trusted) {
1032
+ return 2
1033
+ }
1034
+ argv = trusted.argv
1035
+ const trust = trusted.trust
1036
+ const parsed = parseVetArgs(argv)
1037
+ if (null != parsed.err) {
1038
+ process.stderr.write(parsed.err + '\n')
1039
+ return 2
1040
+ }
1041
+ const args = parsed.args as VetArgs
1042
+
1043
+ if (true === args.help) {
1044
+ process.stdout.write(HELP)
1045
+ return 0
1046
+ }
1047
+
1048
+ if (true === args.watch) {
1049
+ return watchVet(args, wait ?? vetWaiter, trust)
1050
+ }
1051
+
1052
+ return vetOnce(args, trust)
1053
+ }
1054
+
1055
+
1056
+ // ---------------------------------------------------------------------
1057
+ // The subsumption verbs (G3 phase 3): `subsume` asks the query once,
1058
+ // `breaking` asks it between a document and its own earlier versions.
1059
+
1060
+ const SUBSUME_HELP = 'aontu subsume <general> <specific> (try --help)'
1061
+ const BREAKING_HELP =
1062
+ 'aontu breaking --against <file|git#rev> <file> (try --help)'
1063
+
1064
+ type SubsumeFormat = 'text' | 'json'
1065
+
1066
+ // Exit classes mirror vet's convention: 3 is "the truth is not yet
1067
+ // settled", which is exactly what undecided means here — and a gate
1068
+ // that shrugs is not a gate, so undecided FAILS by default.
1069
+ const SUBSUME_EXIT: Record<SubsumeVerdict, number> = {
1070
+ subsumes: 0,
1071
+ does_not_subsume: 1,
1072
+ undecided: 3,
1073
+ error: 4,
1074
+ }
1075
+
1076
+ type SubsumeArgs = {
1077
+ help?: boolean
1078
+ general: string
1079
+ specific: string
1080
+ profile?: SubsumeProfile
1081
+ at?: string
1082
+ format: SubsumeFormat
1083
+ }
1084
+
1085
+ function parseSubsumeArgs(argv: string[]): { args?: SubsumeArgs; err?: string } {
1086
+ const files: string[] = []
1087
+ let profile: SubsumeProfile | undefined
1088
+ let at: string | undefined
1089
+ let format: SubsumeFormat = 'text'
1090
+
1091
+ for (let i = 0; i < argv.length; i++) {
1092
+ const arg = argv[i]
1093
+ if ('-h' === arg || '--help' === arg) {
1094
+ return { args: { help: true, general: '', specific: '', format } }
1095
+ }
1096
+ if ('--profile' === arg) {
1097
+ const p = argv[++i]
1098
+ if ('values' !== p && 'defaults' !== p && 'gen' !== p) {
1099
+ return { err: 'aontu: --profile needs values, defaults or gen' }
1100
+ }
1101
+ profile = p
1102
+ }
1103
+ else if ('--at' === arg) {
1104
+ at = argv[++i]
1105
+ if (null == at) {
1106
+ return { err: 'aontu: --at needs a path' }
1107
+ }
1108
+ }
1109
+ else if ('--format' === arg) {
1110
+ const f = argv[++i]
1111
+ if ('text' !== f && 'json' !== f) {
1112
+ return { err: 'aontu: --format needs text or json' }
1113
+ }
1114
+ format = f
1115
+ }
1116
+ else if (arg.startsWith('-')) {
1117
+ return { err: `aontu: unknown subsume option ${arg} (try --help)` }
1118
+ }
1119
+ else {
1120
+ files.push(arg)
1121
+ }
1122
+ }
1123
+
1124
+ if (2 !== files.length) {
1125
+ return {
1126
+ err: 'aontu: subsume needs a general and a specific file\n' +
1127
+ SUBSUME_HELP,
1128
+ }
1129
+ }
1130
+
1131
+ return {
1132
+ args: { general: files[0], specific: files[1], profile, at, format },
1133
+ }
1134
+ }
1135
+
1136
+ function renderSubsumeText(report: SubsumeReport): string {
1137
+ const head = `verdict: ${report.verdict}`
1138
+ if (0 === report.findings.length) {
1139
+ return head
1140
+ }
1141
+ return [head, ''].concat(report.findings.map(renderFinding)).join('\n')
1142
+ }
1143
+
1144
+ function renderSubsumeJson(report: SubsumeReport): string {
1145
+ return exactJSON({
1146
+ aontu: { version: version(), verb: 'subsume' },
1147
+ verdict: report.verdict,
1148
+ findings: report.findings,
1149
+ }, 2)
1150
+ }
1151
+
1152
+ function runSubsume(argv: string[]): number {
1153
+ const trusted = takeTrust(argv)
1154
+ if (null == trusted) {
1155
+ return 2
1156
+ }
1157
+ argv = trusted.argv
1158
+ const trust = trusted.trust
1159
+ const parsed = parseSubsumeArgs(argv)
1160
+ if (null != parsed.err) {
1161
+ process.stderr.write(parsed.err + '\n')
1162
+ return 2
1163
+ }
1164
+ const args = parsed.args as SubsumeArgs
1165
+
1166
+ if (true === args.help) {
1167
+ process.stdout.write(HELP)
1168
+ return 0
1169
+ }
1170
+
1171
+ let generalSrc: string, specificSrc: string
1172
+ try {
1173
+ generalSrc = readFileSync(args.general, 'utf8')
1174
+ specificSrc = readFileSync(args.specific, 'utf8')
1175
+ }
1176
+ catch (err: any) {
1177
+ process.stderr.write(`aontu: cannot read ${err.path}: ${err.message}\n`)
1178
+ return 2
1179
+ }
1180
+
1181
+ const report = subsume(generalSrc, specificSrc, {
1182
+ trust: verbTrust(trust, entryRootOf(args.general)),
1183
+ profile: args.profile,
1184
+ at: args.at,
1185
+ generalUrl: args.general,
1186
+ specificUrl: args.specific,
1187
+ generalPath: args.general,
1188
+ specificPath: args.specific,
1189
+ })
1190
+
1191
+ const text = 'json' === args.format
1192
+ ? renderSubsumeJson(report)
1193
+ : renderSubsumeText(report)
1194
+ process.stdout.write(text + '\n')
1195
+ return SUBSUME_EXIT[report.verdict]
1196
+ }
1197
+
1198
+
1199
+ type BreakingMode = 'backward' | 'forward' | 'full' | 'none'
1200
+
1201
+ type BreakingArgs = {
1202
+ help?: boolean
1203
+ file: string
1204
+ against: string[]
1205
+ mode?: BreakingMode
1206
+ // `--at`: compare a SUBTREE of both versions. The gate's own
1207
+ // sub-question, and the one a real repository needs -- a document's
1208
+ // top level carries the module's version string and its policy
1209
+ // block, which are supposed to change between releases and which
1210
+ // make the whole-document comparison answer about them rather than
1211
+ // about the contract (use-cases/REVIEW.md finding D). `subsume` has
1212
+ // taken it since G3; `breaking` did not, so the only way to gate a
1213
+ // subtree was to split the file.
1214
+ at?: string
1215
+ allowUndecided: boolean
1216
+ allowDeprecatedRemoval: boolean
1217
+ format: SubsumeFormat
1218
+ }
1219
+
1220
+ function parseBreakingArgs(
1221
+ argv: string[]): { args?: BreakingArgs; err?: string } {
1222
+ const files: string[] = []
1223
+ const against: string[] = []
1224
+ let mode: BreakingMode | undefined
1225
+ let at: string | undefined
1226
+ let allowUndecided = false
1227
+ let allowDeprecatedRemoval = false
1228
+ let format: SubsumeFormat = 'text'
1229
+
1230
+ for (let i = 0; i < argv.length; i++) {
1231
+ const arg = argv[i]
1232
+ if ('-h' === arg || '--help' === arg) {
1233
+ return {
1234
+ args: {
1235
+ help: true, file: '', against: [],
1236
+ allowUndecided, allowDeprecatedRemoval, format,
1237
+ },
1238
+ }
1239
+ }
1240
+ if ('--against' === arg) {
1241
+ const a = argv[++i]
1242
+ if (null == a) {
1243
+ return { err: 'aontu: --against needs a file path or git#<rev>' }
1244
+ }
1245
+ against.push(a)
1246
+ }
1247
+ else if ('--mode' === arg) {
1248
+ const m = argv[++i]
1249
+ if ('backward' !== m && 'forward' !== m && 'full' !== m) {
1250
+ return { err: 'aontu: --mode needs backward, forward or full' }
1251
+ }
1252
+ mode = m
1253
+ }
1254
+ else if ('--at' === arg) {
1255
+ const a = argv[++i]
1256
+ if (null == a) {
1257
+ return { err: 'aontu: --at needs a path' }
1258
+ }
1259
+ at = a
1260
+ }
1261
+ else if ('--allow-undecided' === arg) {
1262
+ allowUndecided = true
1263
+ }
1264
+ else if ('--allow-deprecated-removal' === arg) {
1265
+ allowDeprecatedRemoval = true
1266
+ }
1267
+ else if ('--format' === arg) {
1268
+ const f = argv[++i]
1269
+ if ('text' !== f && 'json' !== f) {
1270
+ return { err: 'aontu: --format needs text or json' }
1271
+ }
1272
+ format = f
1273
+ }
1274
+ else if (arg.startsWith('-')) {
1275
+ return { err: `aontu: unknown breaking option ${arg} (try --help)` }
1276
+ }
1277
+ else {
1278
+ files.push(arg)
1279
+ }
1280
+ }
1281
+
1282
+ if (1 !== files.length || 0 === against.length) {
1283
+ return {
1284
+ err: 'aontu: breaking needs one file and at least one --against\n' +
1285
+ BREAKING_HELP,
1286
+ }
1287
+ }
1288
+
1289
+ return {
1290
+ args: {
1291
+ file: files[0], against, mode, at,
1292
+ allowUndecided, allowDeprecatedRemoval, format,
1293
+ },
1294
+ }
1295
+ }
1296
+
1297
+ // One resolved `--against` spelling: the old document's text, the path
1298
+ // its own relative includes must resolve from, and (for a git spelling)
1299
+ // the temporary tree to remove when the run is done.
1300
+ type OldVersion = { src: string, path: string, temp?: string }
1301
+
1302
+ // A source file the include resolver can actually load. `git#<rev>`
1303
+ // materialises these and nothing else: an include names an Aontu
1304
+ // document (`.aon`/`.aontu`, the two extensions `@"foo"` tries) or a
1305
+ // JSON one, so the rest of a revision's tree cannot be part of any
1306
+ // include closure and copying it would be pure cost.
1307
+ const INCLUDABLE = /\.(aon|aontu|jsonic|json)$/
1308
+
1309
+ // Resolve one --against spelling to an old version.
1310
+ //
1311
+ // A `git#<rev>` spelling is the old version of the WHOLE TREE, not of
1312
+ // the entry file alone. It used to be `git show <rev>:./<file>`, whose
1313
+ // text was then evaluated with `generalPath`/`specificPath` pointing at
1314
+ // the WORKING file -- so every `@"..."` include in the old document
1315
+ // resolved against the working tree, and the "old" side was old entry
1316
+ // text meeting new includes. A breaking change inside an included file
1317
+ // therefore compared against itself and answered `compatible`: the
1318
+ // documented CI gate silently un-gated every non-entry file of the
1319
+ // multi-file layout real models use (use-cases/BUGS.md §26). The old
1320
+ // tree's includable sources are copied into a temporary directory and
1321
+ // the old document is evaluated from THERE.
1322
+ //
1323
+ // Sources outside the revision -- package includes under node_modules,
1324
+ // the bundled `std/system` -- still resolve as they do today: they are
1325
+ // not in the tree, and their versions travel with the lockfile rather
1326
+ // than with this comparison.
1327
+ function oldVersion(spec: string, file: string): OldVersion | undefined {
1328
+ if (!spec.startsWith('git#')) {
1329
+ try {
1330
+ return { src: readFileSync(spec, 'utf8'), path: spec }
1331
+ }
1332
+ catch (err: any) {
1333
+ process.stderr.write(`aontu: cannot read ${err.path}: ${err.message}\n`)
1334
+ return undefined
1335
+ }
1336
+ }
1337
+
1338
+ const rev = spec.slice('git#'.length)
1339
+ if ('' === rev) {
1340
+ process.stderr.write('aontu: --against git# needs a revision\n')
1341
+ return undefined
1342
+ }
1343
+
1344
+ // Lazy import: the dependency exists only when a git spelling is
1345
+ // actually used, so plain runs never pay for it.
1346
+ const { execFileSync } = require('node:child_process')
1347
+ const dir = dirname(resolve(file))
1348
+ const git = (args: string[], cwd: string): string =>
1349
+ execFileSync('git', args, {
1350
+ cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
1351
+ })
1352
+
1353
+ // The temporary tree is made BEFORE the first git call, so every
1354
+ // failure below has exactly one cleanup path rather than a branch
1355
+ // that only some failures take.
1356
+ const temp = mkdtempSync(join(tmpdir(), 'aontu-against-'))
1357
+ try {
1358
+ // THE REPO-RELATIVE PATH COMES FROM GIT, not from path arithmetic.
1359
+ // Relativising `rev-parse --show-toplevel` against `resolve(file)`
1360
+ // puts two DIFFERENT COORDINATE SYSTEMS on either side of the
1361
+ // subtraction: git prints the real path, while the caller's is
1362
+ // whatever they typed. On macOS a temp file under /var is
1363
+ // /private/var to git, and on Windows a TMP short name
1364
+ // (RUNNER~1) is the long form to git -- so the subtraction gave a
1365
+ // `../..` climb, the entry was "not in that revision", and the
1366
+ // documented CI spelling failed on both platforms while passing on
1367
+ // Linux (this PR's own CI). `--show-prefix` is the same question
1368
+ // asked in git's coordinates: the repo-relative directory of the
1369
+ // cwd, already slash-separated and already normalised.
1370
+ const prefix = git(['rev-parse', '--show-prefix'], dir).trim()
1371
+ const entryRel = prefix + basename(file)
1372
+ const top = git(['rev-parse', '--show-toplevel'], dir).trim()
1373
+
1374
+ // `-z` so a path with a newline or a quote cannot be mistaken for
1375
+ // two paths (git otherwise quotes such names).
1376
+ const listed = git(['ls-tree', '-r', '-z', '--name-only', rev], top)
1377
+ .split('\0').filter((p) => '' !== p)
1378
+ if (!listed.includes(entryRel)) {
1379
+ throw new Error(`${entryRel} is not in that revision`)
1380
+ }
1381
+
1382
+ for (const rel of listed) {
1383
+ if (!INCLUDABLE.test(rel)) {
1384
+ continue
1385
+ }
1386
+ const dest = join(temp, ...rel.split('/'))
1387
+ mkdirSync(dirname(dest), { recursive: true })
1388
+ writeFileSync(dest, git(['show', `${rev}:${rel}`], top))
1389
+ }
1390
+
1391
+ const entry = join(temp, ...entryRel.split('/'))
1392
+ return { src: readFileSync(entry, 'utf8'), path: entry, temp }
1393
+ }
1394
+ catch (err: any) {
1395
+ rmSync(temp, { recursive: true, force: true })
1396
+ const detail = String(err.stderr ?? err.message).trim().split('\n')[0]
1397
+ process.stderr.write(`aontu: cannot resolve ${spec}: ${detail}\n`)
1398
+ return undefined
1399
+ }
1400
+ }
1401
+
1402
+ // The document's own compatibility declaration: `$.aontu_policy.compat`,
1403
+ // a disjunction whose default is the declared mode. Undefined when the
1404
+ // key is absent or does not spell a mode.
1405
+ function policyCompat(
1406
+ newSrc: string, path: string, trust: any): BreakingMode | undefined {
1407
+ const aontu = new Aontu()
1408
+ const ctx = aontu.ctx({ collect: true })
1409
+ // The declaration is read by EVALUATING the document, so this leg
1410
+ // runs the include resolver too and has to run it under the verb's
1411
+ // capability -- a `breaking --trust none` that read its own mode
1412
+ // through an unconfined resolver would confine the comparison and
1413
+ // not the question (use-cases/REVIEW.md finding G).
1414
+ const v: any = aontu.unify(newSrc, { path, ...(null == trust ? {} : { trust }) }, ctx)
1415
+ if (0 < ctx.err.length || true === v?.isNil) {
1416
+ return undefined
1417
+ }
1418
+ let compat: any = v?.peg?.aontu_policy?.peg?.compat
1419
+ if (null == compat) {
1420
+ return undefined
1421
+ }
1422
+ if (true === compat.isDisjunct && Array.isArray(compat.peg)) {
1423
+ compat = compat.peg.find((m: any) => true === m?.isPref) ?? compat.peg[0]
1424
+ }
1425
+ if (true === compat.isPref) {
1426
+ compat = compat.peg
1427
+ }
1428
+ const m = true === compat?.isString ? compat.peg : undefined
1429
+ return 'backward' === m || 'forward' === m || 'full' === m || 'none' === m
1430
+ ? m : undefined
1431
+ }
1432
+
1433
+ // Is the evaluated old version's value at the finding path deprecated?
1434
+ // The --allow-deprecated-removal downgrade (G3 phase 4): removing (or
1435
+ // otherwise changing) a value the old version already deprecated warns
1436
+ // instead of breaking. The Go port exports the same reader as
1437
+ // aontu.DeprecatedAt.
1438
+ function deprecatedAt(oldSrc: string, path: string, filePath: string): boolean {
1439
+ const aontu = new Aontu()
1440
+ const ctx = aontu.ctx({ collect: true })
1441
+ const v: any = aontu.unify(oldSrc, { path: filePath }, ctx)
1442
+ if (0 < ctx.err.length || true === v?.isNil) {
1443
+ return false
1444
+ }
1445
+ const segs = path.replace(/^\$/, '').split('.').filter((p) => '' !== p)
1446
+ let node: any = v
1447
+ for (const seg of segs) {
1448
+ if (true === node?.isMap) {
1449
+ node = node.peg?.[seg]
1450
+ }
1451
+ else if (true === node?.isList) {
1452
+ node = node.peg?.[Number(seg)]
1453
+ }
1454
+ else {
1455
+ return false
1456
+ }
1457
+ if (null == node) {
1458
+ return false
1459
+ }
1460
+ }
1461
+ return null != node?.deprecation
1462
+ }
1463
+
1464
+
1465
+ // Verdict aggregation for breaking: an error anywhere makes the run an
1466
+ // error; otherwise a witness anywhere makes it breaking; otherwise an
1467
+ // open question anywhere leaves it undecided.
1468
+ const BREAKING_RANK: Record<SubsumeVerdict, number> = {
1469
+ subsumes: 0,
1470
+ undecided: 1,
1471
+ does_not_subsume: 2,
1472
+ error: 3,
1473
+ }
1474
+
1475
+ const BREAKING_EXIT: Record<SubsumeVerdict, number> = SUBSUME_EXIT
1476
+
1477
+ const BREAKING_VERDICT: Record<SubsumeVerdict, string> = {
1478
+ subsumes: 'compatible',
1479
+ does_not_subsume: 'breaking',
1480
+ undecided: 'undecided',
1481
+ error: 'error',
1482
+ }
1483
+
1484
+ function runBreaking(argv: string[]): number {
1485
+ const trusted = takeTrust(argv)
1486
+ if (null == trusted) {
1487
+ return 2
1488
+ }
1489
+ argv = trusted.argv
1490
+ const trust = trusted.trust
1491
+ const parsed = parseBreakingArgs(argv)
1492
+ if (null != parsed.err) {
1493
+ process.stderr.write(parsed.err + '\n')
1494
+ return 2
1495
+ }
1496
+ const args = parsed.args as BreakingArgs
1497
+
1498
+ if (true === args.help) {
1499
+ process.stdout.write(HELP)
1500
+ return 0
1501
+ }
1502
+
1503
+ let newSrc: string
1504
+ try {
1505
+ newSrc = readFileSync(args.file, 'utf8')
1506
+ }
1507
+ catch (err: any) {
1508
+ process.stderr.write(`aontu: cannot read ${err.path}: ${err.message}\n`)
1509
+ return 2
1510
+ }
1511
+
1512
+ // The declared mode: --mode overrides the document's own policy;
1513
+ // neither means backward, the index's framing (v1-valid documents
1514
+ // stay valid).
1515
+ const mode: BreakingMode =
1516
+ args.mode ??
1517
+ policyCompat(newSrc, args.file, verbTrust(trust, entryRootOf(args.file))) ??
1518
+ 'backward'
1519
+
1520
+ if ('none' === mode) {
1521
+ // The document declares no compatibility promise: nothing to check.
1522
+ const report: SubsumeReport = { verdict: 'subsumes', findings: [] }
1523
+ const text = 'json' === args.format
1524
+ ? renderBreakingJson(report, mode)
1525
+ : renderBreakingText(report)
1526
+ process.stdout.write(text + '\n')
1527
+ return 0
1528
+ }
1529
+
1530
+ let worst: SubsumeVerdict = 'subsumes'
1531
+ const findings: VetFinding[] = []
1532
+
1533
+ // Temporary trees materialised for `git#<rev>` spellings, removed
1534
+ // once every check that reads them has run.
1535
+ const temps: string[] = []
1536
+ const sweep = () => {
1537
+ for (const t of temps) {
1538
+ rmSync(t, { recursive: true, force: true })
1539
+ }
1540
+ }
1541
+
1542
+ try {
1543
+ for (const spec of args.against) {
1544
+ const old = oldVersion(spec, args.file)
1545
+ if (null == old) {
1546
+ return 2
1547
+ }
1548
+ const oldSrc = old.src
1549
+ if (null != old.temp) {
1550
+ temps.push(old.temp)
1551
+ }
1552
+
1553
+ // backward: the NEW document is the general side — every old
1554
+ // instance must still be admitted. forward: the old one is.
1555
+ const checks: Array<{ general: [string, string], specific: [string, string] }> = []
1556
+ if ('backward' === mode || 'full' === mode) {
1557
+ checks.push({ general: [newSrc, args.file], specific: [oldSrc, spec] })
1558
+ }
1559
+ if ('forward' === mode || 'full' === mode) {
1560
+ checks.push({ general: [oldSrc, spec], specific: [newSrc, args.file] })
1561
+ }
1562
+
1563
+ const oldPath = old.path
1564
+
1565
+ for (const check of checks) {
1566
+ const report = subsume(check.general[0], check.specific[0], {
1567
+ trust: verbTrust(trust, entryRootOf(args.file)),
1568
+ at: args.at,
1569
+ generalUrl: check.general[1],
1570
+ specificUrl: check.specific[1],
1571
+ // The old side's relative loads resolve from ITS own tree --
1572
+ // the materialised revision for a git spelling, the named
1573
+ // file's directory otherwise -- so an included file's change
1574
+ // is part of the comparison rather than invisible to it.
1575
+ generalPath: check.general[1] === spec ? oldPath : args.file,
1576
+ specificPath: check.specific[1] === spec ? oldPath : args.file,
1577
+ })
1578
+
1579
+ // The deprecated-removal downgrade: a finding about a value the
1580
+ // OLD version already deprecated becomes a warning, and warnings
1581
+ // do not move the verdict. Deprecate-then-remove is the
1582
+ // supported rename path (the design's own sequencing).
1583
+ let verdict = report.verdict
1584
+ if (args.allowDeprecatedRemoval) {
1585
+ let liveFindings = 0
1586
+ for (const f of report.findings) {
1587
+ if ('error' === f.severity &&
1588
+ deprecatedAt(oldSrc, f.path, oldPath)) {
1589
+ f.severity = 'warning'
1590
+ }
1591
+ if ('error' === f.severity) {
1592
+ liveFindings++
1593
+ }
1594
+ }
1595
+ if ('does_not_subsume' === verdict && 0 === liveFindings) {
1596
+ verdict = 'subsumes'
1597
+ }
1598
+ }
1599
+
1600
+ if (BREAKING_RANK[worst] < BREAKING_RANK[verdict]) {
1601
+ worst = verdict
1602
+ }
1603
+ findings.push(...report.findings)
1604
+ }
1605
+ }
1606
+ }
1607
+ finally {
1608
+ sweep()
1609
+ }
1610
+
1611
+ const report: SubsumeReport = { verdict: worst, findings }
1612
+ const text = 'json' === args.format
1613
+ ? renderBreakingJson(report, mode)
1614
+ : renderBreakingText(report)
1615
+ process.stdout.write(text + '\n')
1616
+
1617
+ if ('undecided' === worst && args.allowUndecided) {
1618
+ return 0
1619
+ }
1620
+ return BREAKING_EXIT[worst]
1621
+ }
1622
+
1623
+ function renderBreakingText(report: SubsumeReport): string {
1624
+ const head = `verdict: ${BREAKING_VERDICT[report.verdict]}`
1625
+ if (0 === report.findings.length) {
1626
+ return head
1627
+ }
1628
+ return [head, ''].concat(report.findings.map(renderFinding)).join('\n')
1629
+ }
1630
+
1631
+ function renderBreakingJson(report: SubsumeReport, mode: string): string {
1632
+ return exactJSON({
1633
+ aontu: { version: version(), verb: 'breaking', mode },
1634
+ verdict: BREAKING_VERDICT[report.verdict],
1635
+ findings: report.findings,
1636
+ }, 2)
1637
+ }
1638
+
1639
+
1640
+ // ---------------------------------------------------------------------
1641
+ // The trim reporter (G3 phase 6): report redundant entries as paths.
1642
+ // Report-only — REWRITING needs G7's format-preserving patch surface —
1643
+ // which is why --check is REQUIRED rather than defaulted: `aontu trim
1644
+ // f.aon` reads as "trim this file", and doing something else silently
1645
+ // is worse than saying so.
1646
+
1647
+ const TRIM_HELP = 'aontu trim --check <file> (try --help)'
1648
+
1649
+ const TRIM_EXIT: Record<TrimVerdict, number> = {
1650
+ clean: 0,
1651
+ redundant: 1,
1652
+ error: 4,
1653
+ }
1654
+
1655
+ function runTrim(argv: string[]): number {
1656
+ const trusted = takeTrust(argv)
1657
+ if (null == trusted) {
1658
+ return 2
1659
+ }
1660
+ argv = trusted.argv
1661
+ const trust = trusted.trust
1662
+ const files: string[] = []
1663
+ let check = false
1664
+ let format: SubsumeFormat = 'text'
1665
+
1666
+ for (let i = 0; i < argv.length; i++) {
1667
+ const arg = argv[i]
1668
+ if ('-h' === arg || '--help' === arg) {
1669
+ process.stdout.write(HELP)
1670
+ return 0
1671
+ }
1672
+ if ('--check' === arg) {
1673
+ check = true
1674
+ }
1675
+ else if ('--format' === arg) {
1676
+ const f = argv[++i]
1677
+ if ('text' !== f && 'json' !== f) {
1678
+ process.stderr.write('aontu: --format needs text or json\n')
1679
+ return 2
1680
+ }
1681
+ format = f
1682
+ }
1683
+ else if (arg.startsWith('-')) {
1684
+ process.stderr.write(`aontu: unknown trim option ${arg} (try --help)\n`)
1685
+ return 2
1686
+ }
1687
+ else {
1688
+ files.push(arg)
1689
+ }
1690
+ }
1691
+
1692
+ if (1 !== files.length) {
1693
+ process.stderr.write(`aontu: trim needs one file\n${TRIM_HELP}\n`)
1694
+ return 2
1695
+ }
1696
+ if (!check) {
1697
+ process.stderr.write(
1698
+ 'aontu: trim only reports for now — rewriting needs a format-' +
1699
+ 'preserving editor (G7); pass --check\n')
1700
+ return 2
1701
+ }
1702
+
1703
+ let src: string
1704
+ try {
1705
+ src = readFileSync(files[0], 'utf8')
1706
+ }
1707
+ catch (err: any) {
1708
+ process.stderr.write(`aontu: cannot read ${err.path}: ${err.message}\n`)
1709
+ return 2
1710
+ }
1711
+
1712
+ const report = trimCheck(src, {
1713
+ path: files[0], trust: verbTrust(trust, entryRootOf(files[0])),
1714
+ })
1715
+ const text = 'json' === format
1716
+ ? renderTrimJson(report)
1717
+ : renderTrimText(report)
1718
+ process.stdout.write(text + '\n')
1719
+ return TRIM_EXIT[report.verdict]
1720
+ }
1721
+
1722
+ function renderTrimText(report: TrimReport): string {
1723
+ const head = `verdict: ${report.verdict}`
1724
+ // WHY, when the document could not be evaluated at all: rendered as
1725
+ // vet renders a finding, because it IS one (the review's finding F).
1726
+ const errors = report.errors ?? []
1727
+ if (0 < errors.length) {
1728
+ return [head, ''].concat(errors.map(renderFinding)).join('\n')
1729
+ }
1730
+ if (0 === report.redundant.length) {
1731
+ return head
1732
+ }
1733
+ return [head, ''].concat(report.redundant).join('\n')
1734
+ }
1735
+
1736
+ function renderTrimJson(report: TrimReport): string {
1737
+ return exactJSON({
1738
+ aontu: { version: version(), verb: 'trim' },
1739
+ verdict: report.verdict,
1740
+ redundant: report.redundant,
1741
+ ...(null == report.errors ? {} : { errors: report.errors }),
1742
+ }, 2)
1743
+ }
1744
+
1745
+
1746
+ // The relation reporter (G4 phase 5): acyclicity and inverse
1747
+ // consistency over the edge set. A verb of its own rather than a leg of
1748
+ // `vet`, for the reason `trim` is one: vet answers "does this DOCUMENT
1749
+ // satisfy that SCHEMA", and these are facts about one finished model,
1750
+ // with no schema on the other side of the question.
1751
+
1752
+ const RELATIONS_HELP = 'aontu relations <file> (try --help)'
1753
+
1754
+ const RELATIONS_EXIT: Record<RelationVerdict, number> = {
1755
+ pass: 0,
1756
+ fail: 1,
1757
+ error: 4,
1758
+ }
1759
+
1760
+ const REACHES_HELP =
1761
+ 'aontu reaches <from> <to> [--relation <name>] <file> (try --help)'
1762
+
1763
+ // Same three-way shape every check verb here uses: the check held (0),
1764
+ // the check failed (1), the document could not be checked (4). An
1765
+ // unreachable pair is a FAILED CHECK and not an error: the question was
1766
+ // answered, and the answer was no.
1767
+ const REACHES_EXIT: Record<ReachVerdict, number> = {
1768
+ reaches: 0,
1769
+ unreachable: 1,
1770
+ error: 4,
1771
+ }
1772
+
1773
+ const VIEW_HELP =
1774
+ 'aontu view <kind> [options] <file>... (try --help)'
1775
+
1776
+ const VIEW_KINDS: ViewKind[] =
1777
+ ['tree', 'matrix', 'graph', 'layer', 'sets', 'layers', 'ladder', 'poset']
1778
+
1779
+ const VIEW_PROFILES: ViewProfile[] = ['text', 'mermaid', 'dot', 'er', 'svg']
1780
+
1781
+ const VIEW_EDGES: ViewEdges[] = ['upward', 'all', 'none']
1782
+
1783
+ // The figure was drawn (0, `lossy` included: the loss report says
1784
+ // what it could not draw, and --strict is the gate on that), or the
1785
+ // document could not be drawn (4). An EMPTY figure is a drawing, not
1786
+ // a failure: a model with no links has nothing to draw, honestly.
1787
+ const VIEW_EXIT: Record<ViewVerdict, number> = {
1788
+ rendered: 0,
1789
+ lossy: 0,
1790
+ error: 4,
1791
+ }
1792
+
1793
+ // The refusals that are USAGE, not the document's fault: exit 2, as
1794
+ // every other verb's usage errors do.
1795
+ const VIEW_USAGE_CODES = [
1796
+ 'view_kind_unknown', 'view_profile_unknown', 'view_rows_exceeded',
1797
+ 'view_at_required', 'view_sets_required', 'view_group_required',
1798
+ 'view_document_shape',
1799
+ ]
1800
+
1801
+ const MOD_HELP = 'aontu mod tidy|verify|vendor|manifest [dir] (try --help)'
1802
+
1803
+ // The module tooling (G6 phase 3, ts/src/mod-tool.ts). All LOCAL:
1804
+ // `tidy` resolves the closure from what is in the stores and rewrites
1805
+ // the lockfile, `verify` asks whether the stores still mean what the
1806
+ // lockfile pins and changes nothing, `vendor` materialises the locked
1807
+ // closure into the project, `manifest` prints what a publish would
1808
+ // push.
1809
+ //
1810
+ // TIDY AND VERIFY ARE DIFFERENT QUESTIONS, and that is why both exist.
1811
+ // Tidy recomputes and rewrites by design -- a pin is what a module
1812
+ // means NOW -- so it makes the lockfile agree with whatever the store
1813
+ // holds, tampering included. Verify is the gate: a CI job runs it
1814
+ // BEFORE tidy, or instead of it.
1815
+ //
1816
+ // `get` and `publish` are the NETWORK half of the design and are not in
1817
+ // this build. They are named here rather than left to fall out as an
1818
+ // unknown subcommand, because a reader of the design will type them and
1819
+ // deserves to be told which half is missing rather than that the word
1820
+ // is wrong.
1821
+ function runMod(argv: string[]): number {
1822
+ const rest: string[] = []
1823
+ let format: SubsumeFormat = 'text'
1824
+ let against: string | undefined
1825
+
1826
+ for (let i = 0; i < argv.length; i++) {
1827
+ const arg = argv[i]
1828
+ if ('-h' === arg || '--help' === arg) {
1829
+ process.stdout.write(HELP)
1830
+ return 0
1831
+ }
1832
+ if ('--format' === arg) {
1833
+ const f = argv[++i]
1834
+ if ('text' !== f && 'json' !== f) {
1835
+ process.stderr.write('aontu: --format needs text or json\n')
1836
+ return 2
1837
+ }
1838
+ format = f
1839
+ }
1840
+ else if ('--against' === arg) {
1841
+ const a = argv[++i]
1842
+ if (null == a) {
1843
+ process.stderr.write('aontu: --against needs a module directory\n')
1844
+ return 2
1845
+ }
1846
+ against = a
1847
+ }
1848
+ else if (arg.startsWith('-')) {
1849
+ process.stderr.write(`aontu: unknown mod option ${arg} (try --help)\n`)
1850
+ return 2
1851
+ }
1852
+ else {
1853
+ rest.push(arg)
1854
+ }
1855
+ }
1856
+
1857
+ const sub = rest[0]
1858
+ const dir = rest[1] ?? '.'
1859
+
1860
+ if ('get' === sub || 'publish' === sub) {
1861
+ process.stderr.write(
1862
+ 'aontu: mod ' + sub + ' needs a registry client, which this build ' +
1863
+ 'does not ship (docs/capability-review/g6-distribution.md)\n')
1864
+ return 2
1865
+ }
1866
+
1867
+ if (!MOD_SUBS.includes(sub) || 2 < rest.length) {
1868
+ process.stderr.write(
1869
+ `aontu: mod needs tidy, verify, vendor or manifest\n${MOD_HELP}\n`)
1870
+ return 2
1871
+ }
1872
+
1873
+ // `--against` gates a manifest and means nothing to the other two;
1874
+ // accepting it there would say it had been honoured.
1875
+ if (null != against && 'manifest' !== sub) {
1876
+ process.stderr.write('aontu: --against is a manifest option\n')
1877
+ return 2
1878
+ }
1879
+
1880
+ const report =
1881
+ 'tidy' === sub ? modTidy(dir, modToolOptions()) :
1882
+ 'verify' === sub ? modVerify(dir, modToolOptions()) :
1883
+ 'vendor' === sub ? modVendor(dir, modToolOptions()) :
1884
+ modManifest(dir, modToolOptions(), against)
1885
+
1886
+ process.stdout.write(('json' === format ?
1887
+ exactJSON({ aontu: { version: version(), verb: 'mod ' + sub }, ...report },
1888
+ 2) :
1889
+ modText(sub, report)) + '\n')
1890
+
1891
+ return MOD_EXIT[report.verdict]
1892
+ }
1893
+
1894
+
1895
+ const MOD_SUBS = ['tidy', 'verify', 'vendor', 'manifest']
1896
+
1897
+ // The verdict classes: `ok` 0, a refused gate 1, an open question 3, a
1898
+ // document that does not stand up 4 -- `subsume`'s classes, because a
1899
+ // manifest gate IS a subsumption check and a caller reading exit codes
1900
+ // should not have to learn a second table.
1901
+ type ModVerdict =
1902
+ ModTidyReport['verdict'] |
1903
+ ModVerifyReport['verdict'] |
1904
+ ModVendorReport['verdict'] |
1905
+ ModManifestReport['verdict']
1906
+
1907
+ const MOD_EXIT: Record<ModVerdict, number> = {
1908
+ ok: 0,
1909
+ missing: 1,
1910
+ // A REFUSED GATE, with `breaking`: a store that no longer means what
1911
+ // the lockfile pins is the integrity check saying no, and a CI job
1912
+ // reading exit codes should not have to learn a third class for it.
1913
+ mismatch: 1,
1914
+ // Likewise a lockfile that does not cover the project: the gate has
1915
+ // nothing to check, which is a refusal and not a pass.
1916
+ unlocked: 1,
1917
+ breaking: 1,
1918
+ undecided: 3,
1919
+ error: 4,
1920
+ }
1921
+
1922
+
1923
+ // The tooling's evaluator: the same standalone evaluation the module
1924
+ // resolver verifies with (ts/src/mod.ts), and for the same reason —
1925
+ // only the engine can say what a module MEANS.
1926
+ function modToolOptions() {
1927
+ return {
1928
+ cache: modCacheDir(),
1929
+ eval: (src: string, path: string) => {
1930
+ const a0 = new Aontu()
1931
+ const ctx = a0.ctx({ collect: true })
1932
+ const val: any = a0.unify(src, { path }, ctx)
1933
+ return {
1934
+ gen: val.gen(a0.ctx({ collect: true })),
1935
+ hash: canonHash(val),
1936
+ canon: val.canon,
1937
+ // The same question `aontu hash` asks before it will answer:
1938
+ // did this document stand up ON ITS OWN? See ModToolEval.
1939
+ ok: 0 === ctx.err.length && true !== val.isNil,
1940
+ }
1941
+ },
1942
+ }
1943
+ }
1944
+
1945
+
1946
+ function modText(sub: string, report: any): string {
1947
+ const lines = ['verdict: ' + report.verdict]
1948
+
1949
+ if ('manifest' === sub) {
1950
+ if ('' !== report.mod) {
1951
+ lines.push(report.mod + ' ' + report.version)
1952
+ lines.push('config: ' + report.config)
1953
+ }
1954
+ for (const key of Object.keys(report.annotations).sort()) {
1955
+ lines.push(key + ': ' + report.annotations[key])
1956
+ }
1957
+ for (const file of report.files) {
1958
+ lines.push('layer: ' + file)
1959
+ }
1960
+ for (const f of report.findings) {
1961
+ lines.push(f.path + ': ' + f.message)
1962
+ }
1963
+ // What a manifest lacks is a declaration the module does not make
1964
+ // or an entry file that is not there, and neither is something a
1965
+ // fetch would supply -- so this is not the tail the other two
1966
+ // subcommands share. The name says which kind it is: `mod.version`
1967
+ // is a declaration, `service.aon` is a file.
1968
+ for (const miss of report.missing) {
1969
+ lines.push(miss + ': missing')
1970
+ }
1971
+ return lines.join('\n')
1972
+ }
1973
+
1974
+ if ('verify' === sub) {
1975
+ for (const mod of report.verified) {
1976
+ lines.push(mod + ': verified')
1977
+ }
1978
+ // BOTH HASHES, because the useful question is which way it moved:
1979
+ // an empty `got` is a module that no longer stands up at all.
1980
+ for (const m of report.mismatched) {
1981
+ lines.push(m.mod + ': pinned ' + m.want + ' but the store means ' +
1982
+ ('' === m.got ? 'nothing (it does not evaluate)' : m.got))
1983
+ }
1984
+ // NOT a fetch: the module may well be sitting in the store. What
1985
+ // is absent is the PIN, and only a tidy writes one.
1986
+ for (const mod of report.unlocked) {
1987
+ lines.push(mod + ': not in the lockfile (run: aontu mod tidy)')
1988
+ }
1989
+ for (const miss of report.missing) {
1990
+ lines.push(miss + ': not fetched (run: aontu mod get)')
1991
+ }
1992
+ return lines.join('\n')
1993
+ }
1994
+
1995
+ const done: any[] = 'tidy' === sub ? report.lock : report.vendored
1996
+ for (const item of done) {
1997
+ lines.push('tidy' === sub ?
1998
+ item.mod + ' ' + item.v + ' ' + item.canon : '' + item)
1999
+ }
2000
+ // A module that is PRESENT but does not stand up. Named separately
2001
+ // from a missing one because the repair is different: a fetch cannot
2002
+ // help, the module itself has to be fixed (or its own dependencies
2003
+ // vendored beside it). Before the missing tail, as the Go port's
2004
+ // shared renderer orders them.
2005
+ for (const bad of report.unevaluable ?? []) {
2006
+ lines.push(bad + ': does not evaluate on its own; nothing to pin')
2007
+ }
2008
+ for (const miss of report.missing) {
2009
+ lines.push(miss + ': not fetched (run: aontu mod get)')
2010
+ }
2011
+ return lines.join('\n')
2012
+ }
2013
+
2014
+
2015
+ function runRelations(argv: string[]): number {
2016
+ const trusted = takeTrust(argv)
2017
+ if (null == trusted) {
2018
+ return 2
2019
+ }
2020
+ argv = trusted.argv
2021
+ const trust = trusted.trust
2022
+ const files: string[] = []
2023
+ let format: SubsumeFormat = 'text'
2024
+
2025
+ for (let i = 0; i < argv.length; i++) {
2026
+ const arg = argv[i]
2027
+ if ('-h' === arg || '--help' === arg) {
2028
+ process.stdout.write(HELP)
2029
+ return 0
2030
+ }
2031
+ if ('--format' === arg) {
2032
+ const f = argv[++i]
2033
+ if ('text' !== f && 'json' !== f) {
2034
+ process.stderr.write('aontu: --format needs text or json\n')
2035
+ return 2
2036
+ }
2037
+ format = f
2038
+ }
2039
+ else if (arg.startsWith('-')) {
2040
+ process.stderr.write(`aontu: unknown relations option ${arg} (try --help)\n`)
2041
+ return 2
2042
+ }
2043
+ else {
2044
+ files.push(arg)
2045
+ }
2046
+ }
2047
+
2048
+ if (1 !== files.length) {
2049
+ process.stderr.write(`aontu: relations needs one file\n${RELATIONS_HELP}\n`)
2050
+ return 2
2051
+ }
2052
+
2053
+ let src: string
2054
+ try {
2055
+ src = readFileSync(files[0], 'utf8')
2056
+ }
2057
+ catch (err: any) {
2058
+ process.stderr.write(`aontu: cannot read ${err.path}: ${err.message}\n`)
2059
+ return 2
2060
+ }
2061
+
2062
+ const report = relationCheck(src, {
2063
+ path: files[0], trust: verbTrust(trust, entryRootOf(files[0])),
2064
+ })
2065
+ const text = 'json' === format
2066
+ ? renderRelationsJson(report)
2067
+ : renderRelationsText(report)
2068
+ process.stdout.write(text + '\n')
2069
+ return RELATIONS_EXIT[report.verdict]
2070
+ }
2071
+
2072
+ function runReaches(argv: string[]): number {
2073
+ const trusted = takeTrust(argv)
2074
+ if (null == trusted) {
2075
+ return 2
2076
+ }
2077
+ argv = trusted.argv
2078
+ const trust = trusted.trust
2079
+ const rest: string[] = []
2080
+ let format: SubsumeFormat = 'text'
2081
+ let relation: string | undefined = undefined
2082
+
2083
+ for (let i = 0; i < argv.length; i++) {
2084
+ const arg = argv[i]
2085
+ if ('-h' === arg || '--help' === arg) {
2086
+ process.stdout.write(HELP)
2087
+ return 0
2088
+ }
2089
+ if ('--format' === arg) {
2090
+ const f = argv[++i]
2091
+ if ('text' !== f && 'json' !== f) {
2092
+ process.stderr.write('aontu: --format needs text or json\n')
2093
+ return 2
2094
+ }
2095
+ format = f
2096
+ }
2097
+ else if ('--relation' === arg) {
2098
+ relation = argv[++i]
2099
+ if (null == relation) {
2100
+ process.stderr.write('aontu: --relation needs a name\n')
2101
+ return 2
2102
+ }
2103
+ }
2104
+ else if (arg.startsWith('-')) {
2105
+ process.stderr.write(
2106
+ `aontu: unknown reaches option ${arg} (try --help)\n`)
2107
+ return 2
2108
+ }
2109
+ else {
2110
+ rest.push(arg)
2111
+ }
2112
+ }
2113
+
2114
+ if (3 !== rest.length) {
2115
+ process.stderr.write(
2116
+ `aontu: reaches needs two node paths and one file\n${REACHES_HELP}\n`)
2117
+ return 2
2118
+ }
2119
+
2120
+ let src: string
2121
+ try {
2122
+ src = readFileSync(rest[2], 'utf8')
2123
+ }
2124
+ catch (err: any) {
2125
+ process.stderr.write(`aontu: cannot read ${err.path}: ${err.message}\n`)
2126
+ return 2
2127
+ }
2128
+
2129
+ const report = reachCheck(src, rest[0], rest[1], {
2130
+ path: rest[2], relation,
2131
+ trust: verbTrust(trust, entryRootOf(rest[2])),
2132
+ })
2133
+ const text = 'json' === format
2134
+ ? renderReachesJson(report)
2135
+ : renderReachesText(report, rest[0], rest[1])
2136
+ process.stdout.write(text + '\n')
2137
+ return REACHES_EXIT[report.verdict]
2138
+ }
2139
+
2140
+ function renderReachesText(
2141
+ report: ReachReport, from: string, to: string): string {
2142
+ const head = `verdict: ${report.verdict}`
2143
+ const errors = report.errors ?? []
2144
+ if (0 < errors.length) {
2145
+ return [head, ''].concat(errors.map(renderFinding)).join('\n')
2146
+ }
2147
+ // THE PATH IS THE ANSWER, not decoration: "yes" is worth little to an
2148
+ // operator asking what a failure would take out, and the chain is
2149
+ // what they act on.
2150
+ return 'reaches' === report.verdict
2151
+ ? [head, '', (report.path as string[]).join(' -> ')].join('\n')
2152
+ : [head, '', `${from} does not reach ${to}`].join('\n')
2153
+ }
2154
+
2155
+ function renderReachesJson(report: ReachReport): string {
2156
+ return exactJSON({
2157
+ aontu: { version: version(), verb: 'reaches' },
2158
+ verdict: report.verdict,
2159
+ ...(null == report.path ? {} : { path: report.path }),
2160
+ ...(null == report.errors ? {} : { errors: report.errors }),
2161
+ }, 2)
2162
+ }
2163
+
2164
+ // ---------------------------------------------------------------------
2165
+ // The tree view (docs/design/VIEWS.0.md, ts/src/view.ts): the drawn
2166
+ // edge set, as text a golden diff can check.
2167
+
2168
+ function runView(argv: string[]): number {
2169
+ const trusted = takeTrust(argv)
2170
+ if (null == trusted) {
2171
+ return 2
2172
+ }
2173
+ argv = trusted.argv
2174
+ const trust = trusted.trust
2175
+ const rest: string[] = []
2176
+ let format: SubsumeFormat = 'text'
2177
+ let out: string | undefined = undefined
2178
+ let check = false
2179
+ let strict = false
2180
+ const relations: string[] = []
2181
+ const roots: string[] = []
2182
+ const opts: ViewOptions = {}
2183
+
2184
+ // A flag that takes a value, read into `opts` by name.
2185
+ const valued: Record<string, keyof ViewOptions> = {
2186
+ '--as': 'as', '--at': 'at', '--order': 'order', '--group-by': 'groupBy',
2187
+ '--label': 'label', '--sets': 'sets', '--member': 'member',
2188
+ '--universe': 'universe', '--profile': 'profile', '--views': 'views',
2189
+ '--edges': 'edges',
2190
+ }
2191
+ const counted: Record<string, keyof ViewOptions> = {
2192
+ '--max-rows': 'maxRows', '--max-cols': 'maxCols',
2193
+ '--min-degree': 'minDegree', '--min-size': 'minSize',
2194
+ }
2195
+
2196
+ for (let i = 0; i < argv.length; i++) {
2197
+ const arg = argv[i]
2198
+ if ('-h' === arg || '--help' === arg) {
2199
+ process.stdout.write(HELP)
2200
+ return 0
2201
+ }
2202
+ if ('--format' === arg) {
2203
+ const f = argv[++i]
2204
+ if ('text' !== f && 'json' !== f) {
2205
+ process.stderr.write('aontu: --format needs text or json\n')
2206
+ return 2
2207
+ }
2208
+ format = f
2209
+ }
2210
+ else if ('--relation' === arg) {
2211
+ const relation = argv[++i]
2212
+ if (null == relation || '' === relation) {
2213
+ process.stderr.write('aontu: --relation needs a name\n')
2214
+ return 2
2215
+ }
2216
+ relations.push(relation)
2217
+ }
2218
+ else if ('--root' === arg) {
2219
+ const root = argv[++i]
2220
+ if (null == root) {
2221
+ process.stderr.write('aontu: --root needs a node path\n')
2222
+ return 2
2223
+ }
2224
+ roots.push(root)
2225
+ }
2226
+ else if ('-o' === arg || '--out' === arg) {
2227
+ out = argv[++i]
2228
+ if (null == out) {
2229
+ process.stderr.write('aontu: --out needs a file\n')
2230
+ return 2
2231
+ }
2232
+ }
2233
+ else if ('--check' === arg) {
2234
+ check = true
2235
+ }
2236
+ else if ('--strict' === arg) {
2237
+ strict = true
2238
+ }
2239
+ else if ('--closure' === arg) {
2240
+ opts.closure = true
2241
+ }
2242
+ else if ('--layers' === arg) {
2243
+ const v = argv[++i]
2244
+ if (null == v || '' === v) {
2245
+ process.stderr.write('aontu: --layers needs a comma-separated list\n')
2246
+ return 2
2247
+ }
2248
+ opts.layers = v.split(',')
2249
+ }
2250
+ else if (undefined !== valued[arg]) {
2251
+ const v = argv[++i]
2252
+ if (null == v || '' === v) {
2253
+ process.stderr.write(`aontu: ${arg} needs a value\n`)
2254
+ return 2
2255
+ }
2256
+ (opts as any)[valued[arg]] = v
2257
+ }
2258
+ else if (undefined !== counted[arg]) {
2259
+ const v = argv[++i]
2260
+ if (null == v || !/^[0-9]+$/.test(v)) {
2261
+ process.stderr.write(`aontu: ${arg} needs a count\n`)
2262
+ return 2
2263
+ }
2264
+ (opts as any)[counted[arg]] = parseInt(v, 10)
2265
+ }
2266
+ else if (arg.startsWith('-')) {
2267
+ process.stderr.write(
2268
+ `aontu: unknown view option ${arg} (try --help)\n`)
2269
+ return 2
2270
+ }
2271
+ else {
2272
+ rest.push(arg)
2273
+ }
2274
+ }
2275
+
2276
+ // THE VIEW DOCUMENT draws every figure a document declares, so it
2277
+ // names no kind: the declarations do, one each.
2278
+ if (undefined !== opts.views) {
2279
+ return runViewSet(rest, opts, trust, { format, check, strict, out })
2280
+ }
2281
+
2282
+ if (2 > rest.length) {
2283
+ process.stderr.write(
2284
+ `aontu: view needs a kind and a file\n${VIEW_HELP}\n`)
2285
+ return 2
2286
+ }
2287
+ const kind = rest[0] as ViewKind
2288
+ if (!VIEW_KINDS.includes(kind)) {
2289
+ process.stderr.write(
2290
+ `aontu: unknown view kind ${kind} (the kinds are: ${VIEW_KINDS.join(', ')})\n`)
2291
+ return 2
2292
+ }
2293
+ if (undefined !== opts.as && !VIEW_PROFILES.includes(opts.as)) {
2294
+ process.stderr.write(
2295
+ `aontu: --as needs one of ${VIEW_PROFILES.join(', ')}\n`)
2296
+ return 2
2297
+ }
2298
+ if (undefined !== opts.order && 'canon' !== opts.order && 'partition' !== opts.order) {
2299
+ process.stderr.write('aontu: --order needs canon or partition\n')
2300
+ return 2
2301
+ }
2302
+ if (undefined !== opts.edges && !VIEW_EDGES.includes(opts.edges)) {
2303
+ process.stderr.write(
2304
+ `aontu: --edges needs one of ${VIEW_EDGES.join(', ')}\n`)
2305
+ return 2
2306
+ }
2307
+ if (undefined !== opts.profile && !['values', 'defaults', 'gen'].includes(opts.profile)) {
2308
+ process.stderr.write('aontu: --profile needs values, defaults or gen\n')
2309
+ return 2
2310
+ }
2311
+ if ('poset' !== kind && 2 !== rest.length) {
2312
+ process.stderr.write(`aontu: view ${kind} takes one file\n`)
2313
+ return 2
2314
+ }
2315
+ if ('graph' === kind) {
2316
+ opts.relations = relations
2317
+ }
2318
+ else if (1 < relations.length) {
2319
+ process.stderr.write(`aontu: view ${kind} takes one --relation\n`)
2320
+ return 2
2321
+ }
2322
+ else {
2323
+ opts.relation = relations[0]
2324
+ }
2325
+ if (check && undefined === out) {
2326
+ process.stderr.write('aontu: --check needs --out\n')
2327
+ return 2
2328
+ }
2329
+
2330
+ const files = rest.slice(1)
2331
+ const srcs: string[] = []
2332
+ for (const file of files) {
2333
+ try {
2334
+ srcs.push(readFileSync(file, 'utf8'))
2335
+ }
2336
+ catch (err: any) {
2337
+ process.stderr.write(`aontu: cannot read ${err.path}: ${err.message}\n`)
2338
+ return 2
2339
+ }
2340
+ }
2341
+
2342
+ const report = view(srcs[0], {
2343
+ ...opts,
2344
+ kind,
2345
+ path: files[0],
2346
+ roots,
2347
+ trust: verbTrust(trust, entryRootOf(files[0])),
2348
+ docs: files.slice(1).map((path, i) => ({ src: srcs[i + 1], path })),
2349
+ })
2350
+
2351
+ if ('json' === format) {
2352
+ process.stdout.write(renderViewJson(report) + '\n')
2353
+ }
2354
+ else if ('error' === report.verdict) {
2355
+ process.stderr.write(
2356
+ (report.errors as VetFinding[]).map(renderFinding).join('\n') + '\n')
2357
+ }
2358
+ else {
2359
+ // THE FIGURE AND NOTHING ELSE on stdout (or in the file): stdout is
2360
+ // what a golden diff reads, and a verdict line would be part of
2361
+ // every drawing. The loss report goes to stderr, so a figure
2362
+ // written to a file still tells the reader what it could not draw.
2363
+ const text = report.text + '\n'
2364
+ if (undefined === out) {
2365
+ process.stdout.write(text)
2366
+ }
2367
+ else if (check) {
2368
+ let have: string | undefined = undefined
2369
+ try {
2370
+ have = readFileSync(out, 'utf8')
2371
+ }
2372
+ catch (_err: any) {
2373
+ // Absent is a mismatch.
2374
+ }
2375
+ if (have !== text) {
2376
+ process.stderr.write(`aontu: ${out} differs from the ${kind} figure\n`)
2377
+ return 1
2378
+ }
2379
+ }
2380
+ else {
2381
+ writeFileSync(out, text, 'utf8')
2382
+ }
2383
+ if (0 < report.loss.length) {
2384
+ process.stderr.write(renderViewLoss(report.loss) + '\n')
2385
+ }
2386
+ }
2387
+
2388
+ if ('error' === report.verdict) {
2389
+ const code = (report.errors as VetFinding[])[0]?.code
2390
+ return VIEW_USAGE_CODES.includes(code) ? 2 : VIEW_EXIT.error
2391
+ }
2392
+ return strict && 'lossy' === report.verdict ? 1 : VIEW_EXIT[report.verdict]
2393
+ }
2394
+
2395
+ // `aontu view --views <path> <file>`: every figure the document
2396
+ // declares, from one evaluation, all or nothing.
2397
+ //
2398
+ // A declared `out` is resolved against the DOCUMENT's own directory,
2399
+ // not the caller's: a view document is committed beside the figures it
2400
+ // gates, and a gate that only passes from one working directory is not
2401
+ // a gate.
2402
+ function runViewSet(
2403
+ rest: string[], opts: ViewOptions, trust: TrustArg,
2404
+ how: { format: SubsumeFormat, check: boolean, strict: boolean, out?: string }
2405
+ ): number {
2406
+ if (1 !== rest.length) {
2407
+ process.stderr.write('aontu: view --views takes one file\n')
2408
+ return 2
2409
+ }
2410
+ if (undefined !== how.out) {
2411
+ process.stderr.write(
2412
+ 'aontu: --out is per figure in a view document; each declares its own\n')
2413
+ return 2
2414
+ }
2415
+ const file = rest[0]
2416
+ let src: string
2417
+ try {
2418
+ src = readFileSync(file, 'utf8')
2419
+ }
2420
+ catch (err: any) {
2421
+ process.stderr.write(`aontu: cannot read ${err.path}: ${err.message}\n`)
2422
+ return 2
2423
+ }
2424
+
2425
+ const report = viewSet(src, {
2426
+ ...opts, path: file, trust: verbTrust(trust, entryRootOf(file)),
2427
+ })
2428
+
2429
+ if ('json' === how.format) {
2430
+ process.stdout.write(renderViewSetJson(report) + '\n')
2431
+ }
2432
+ else if (undefined !== report.errors) {
2433
+ process.stderr.write(report.errors.map(renderFinding).join('\n') + '\n')
2434
+ }
2435
+ else {
2436
+ for (const fig of report.views) {
2437
+ if (undefined !== fig.errors) {
2438
+ process.stderr.write(`${fig.name} (${fig.kind}):\n` +
2439
+ fig.errors.map(renderFinding).join('\n') + '\n')
2440
+ }
2441
+ else if (0 < fig.loss.length) {
2442
+ process.stderr.write(renderViewLoss(fig.loss)
2443
+ .split('\n').map((l) => `${fig.name} ${l}`).join('\n') + '\n')
2444
+ }
2445
+ }
2446
+ }
2447
+ if ('error' === report.verdict) {
2448
+ return setExit(report)
2449
+ }
2450
+
2451
+ // EVERY FIGURE RENDERED, so the whole set is written -- or, under
2452
+ // --check, the whole set is compared and every difference named.
2453
+ const dir = dirname(resolve(file))
2454
+ let differ = 0
2455
+ for (const fig of report.views) {
2456
+ const path = resolve(dir, fig.out)
2457
+ const text = fig.text + '\n'
2458
+ if (how.check) {
2459
+ let have: string | undefined = undefined
2460
+ try {
2461
+ have = readFileSync(path, 'utf8')
2462
+ }
2463
+ catch (_err: any) {
2464
+ // Absent is a mismatch.
2465
+ }
2466
+ if (have !== text) {
2467
+ differ++
2468
+ process.stderr.write(
2469
+ `aontu: ${fig.out} differs from the ${fig.name} figure\n`)
2470
+ }
2471
+ }
2472
+ else {
2473
+ try {
2474
+ writeFileSync(path, text, 'utf8')
2475
+ }
2476
+ catch (err: any) {
2477
+ process.stderr.write(`aontu: cannot write ${err.path}: ${err.message}\n`)
2478
+ return 2
2479
+ }
2480
+ if ('json' !== how.format) {
2481
+ process.stderr.write(`wrote ${fig.out} ${fig.name} (${fig.kind})\n`)
2482
+ }
2483
+ }
2484
+ }
2485
+ if (0 < differ) {
2486
+ return 1
2487
+ }
2488
+ return how.strict && 'lossy' === report.verdict ? 1 : VIEW_EXIT[report.verdict]
2489
+ }
2490
+
2491
+
2492
+ // A set's exit code is the worst of its figures': a usage refusal
2493
+ // anywhere is usage, and any other refusal is the document's fault.
2494
+ function setExit(report: ViewSetReport): number {
2495
+ const codes = [
2496
+ ...(report.errors ?? []),
2497
+ ...report.views.flatMap((v) => v.errors ?? []),
2498
+ ].map((e) => e.code)
2499
+ return codes.some((c) => VIEW_USAGE_CODES.includes(c))
2500
+ ? 2 : VIEW_EXIT.error
2501
+ }
2502
+
2503
+
2504
+ function renderViewSetJson(report: ViewSetReport): string {
2505
+ return exactJSON({
2506
+ aontu: { version: version(), verb: 'view' },
2507
+ verdict: report.verdict,
2508
+ views: report.views.map((v: ViewFigure) => ({
2509
+ name: v.name,
2510
+ kind: v.kind,
2511
+ out: v.out,
2512
+ verdict: v.verdict,
2513
+ ...(null == v.text ? {} : { text: v.text }),
2514
+ loss: v.loss,
2515
+ ...(null == v.errors ? {} : { errors: v.errors }),
2516
+ })),
2517
+ ...(null == report.errors ? {} : { errors: report.errors }),
2518
+ }, 2)
2519
+ }
2520
+
2521
+
2522
+ // One line per code: the code, the count, and the detail if any.
2523
+ function renderViewLoss(loss: ViewLoss[]): string {
2524
+ return loss.map((l) =>
2525
+ `${l.code} ${l.count}` +
2526
+ (undefined === l.detail ? '' : ' ' + l.detail.join(' '))).join('\n')
2527
+ }
2528
+
2529
+ function renderViewJson(report: ViewReport): string {
2530
+ return exactJSON({
2531
+ aontu: { version: version(), verb: 'view' },
2532
+ kind: report.kind,
2533
+ verdict: report.verdict,
2534
+ ...(null == report.text ? {} : { text: report.text }),
2535
+ loss: report.loss,
2536
+ ...(null == report.errors ? {} : { errors: report.errors }),
2537
+ }, 2)
2538
+ }
2539
+
2540
+ function renderRelationsText(report: RelationReport): string {
2541
+ const head = `verdict: ${report.verdict}`
2542
+ // WHY, when the document could not be evaluated at all: rendered as
2543
+ // vet renders a finding, because it IS one (the review's finding F).
2544
+ const errors = report.errors ?? []
2545
+ if (0 < errors.length) {
2546
+ return [head, ''].concat(errors.map(renderFinding)).join('\n')
2547
+ }
2548
+ if (0 === report.findings.length) {
2549
+ return head
2550
+ }
2551
+ const lines = report.findings.map((f) =>
2552
+ 'relation_cycle' === f.code
2553
+ ? `${f.at} ${f.relation}: cycle ${f.detail.join(' -> ')}`
2554
+ : `${f.at} ${f.relation}: ${f.detail[1]} does not list ` +
2555
+ `${f.detail[0]} under ${f.detail[2]}`)
2556
+ return [head, ''].concat(lines).join('\n')
2557
+ }
2558
+
2559
+ function renderRelationsJson(report: RelationReport): string {
2560
+ return exactJSON({
2561
+ aontu: { version: version(), verb: 'relations' },
2562
+ verdict: report.verdict,
2563
+ findings: report.findings,
2564
+ ...(null == report.errors ? {} : { errors: report.errors }),
2565
+ }, 2)
2566
+ }
2567
+
2568
+
2569
+
2570
+ // ---------------------------------------------------------------------
2571
+ // JSON SCHEMA EXPORT (SUPPORT.md act 2, the review's finding I): the
2572
+ // bridge to every structured-output API, which constrains generation to
2573
+ // JSON Schema and nothing else. Export the model, let the provider
2574
+ // generate under it, then `vet` the result against the model itself --
2575
+ // the hybrid an enterprise actually deploys, and impossible without
2576
+ // this verb.
2577
+ //
2578
+ // THE SCHEMA GOES TO STDOUT AND THE LOSSES TO STDERR, so `aontu
2579
+ // jsonschema x.aon > schema.json` writes a schema and still tells the
2580
+ // reader what it could not carry. `--strict` makes a loss a refusal,
2581
+ // for the CI job that would rather fail than ship a schema weaker than
2582
+ // its model.
2583
+
2584
+ const JSONSCHEMA_HELP =
2585
+ 'aontu jsonschema [--at <path>] [--strict] <file> (try --help)'
2586
+
2587
+ function runJsonSchema(argv: string[]): number {
2588
+ const trusted = takeTrust(argv)
2589
+ if (null == trusted) {
2590
+ return 2
2591
+ }
2592
+ argv = trusted.argv
2593
+ const trust = trusted.trust
2594
+ const files: string[] = []
2595
+ let format: SubsumeFormat = 'text'
2596
+ let at: string | undefined = undefined
2597
+ let strict = false
2598
+
2599
+ for (let i = 0; i < argv.length; i++) {
2600
+ const arg = argv[i]
2601
+ if ('-h' === arg || '--help' === arg) {
2602
+ process.stdout.write(HELP)
2603
+ return 0
2604
+ }
2605
+ if ('--format' === arg) {
2606
+ const f = argv[++i]
2607
+ if ('text' !== f && 'json' !== f) {
2608
+ process.stderr.write('aontu: --format needs text or json\n')
2609
+ return 2
2610
+ }
2611
+ format = f
2612
+ }
2613
+ else if ('--at' === arg) {
2614
+ at = argv[++i]
2615
+ if (null == at) {
2616
+ process.stderr.write('aontu: --at needs a path\n')
2617
+ return 2
2618
+ }
2619
+ }
2620
+ else if ('--strict' === arg) {
2621
+ strict = true
2622
+ }
2623
+ else if (arg.startsWith('-')) {
2624
+ process.stderr.write(
2625
+ `aontu: unknown jsonschema option ${arg} (try --help)\n`)
2626
+ return 2
2627
+ }
2628
+ else {
2629
+ files.push(arg)
2630
+ }
2631
+ }
2632
+
2633
+ if (1 !== files.length) {
2634
+ process.stderr.write(
2635
+ `aontu: jsonschema needs one file\n${JSONSCHEMA_HELP}\n`)
2636
+ return 2
2637
+ }
2638
+
2639
+ let src: string
2640
+ try {
2641
+ src = readFileSync(files[0], 'utf8')
2642
+ }
2643
+ catch (err: any) {
2644
+ process.stderr.write(`aontu: cannot read ${err.path}: ${err.message}\n`)
2645
+ return 2
2646
+ }
2647
+
2648
+ const report = jsonSchema(src, {
2649
+ at, path: files[0], trust: verbTrust(trust, entryRootOf(files[0])),
2650
+ })
2651
+
2652
+ if ('json' === format) {
2653
+ process.stdout.write(exactJSON({
2654
+ aontu: { version: version(), verb: 'jsonschema' },
2655
+ verdict: report.verdict,
2656
+ schema: report.schema,
2657
+ lossy: report.lossy,
2658
+ ...(null == report.errors ? {} : { errors: report.errors }),
2659
+ }, 2) + '\n')
2660
+ }
2661
+ else if ('error' === report.verdict) {
2662
+ // Not `?? []`: every `error` return in jsonSchema() sets `errors`,
2663
+ // so the list is the reason for the refusal rather than a maybe,
2664
+ // exactly as Go's `r.Errors` is on this arm.
2665
+ process.stderr.write(
2666
+ (report.errors as VetFinding[]).map(renderFinding).join('\n') + '\n')
2667
+ }
2668
+ else {
2669
+ process.stdout.write(exactJSON(report.schema, 2) + '\n')
2670
+ for (const l of report.lossy) {
2671
+ process.stderr.write(`lossy: ${l.path} ${l.construct}: ${l.reason}\n`)
2672
+ }
2673
+ }
2674
+
2675
+ return 'error' === report.verdict ? 4 :
2676
+ strict && 'lossy' === report.verdict ? 1 : 0
2677
+ }
2678
+
2679
+ // ---------------------------------------------------------------------
2680
+ // The canon-hash (G6 phase 1): the pin an agent, a lockfile or a
2681
+ // registry stores for "this module, this meaning". The hash covers the
2682
+ // module evaluated STANDALONE -- its own include closure resolved and
2683
+ // unified at its own root, before any consumer context -- which is what
2684
+ // makes the pin transitive: an edit two includes deep changes the
2685
+ // unified root, hence the hash.
2686
+
2687
+ const HASH_HELP = 'aontu hash <file> (try --help)'
2688
+
2689
+ function runHash(argv: string[]): number {
2690
+ const trusted = takeTrust(argv)
2691
+ if (null == trusted) {
2692
+ return 2
2693
+ }
2694
+ argv = trusted.argv
2695
+ const trust = trusted.trust
2696
+ const files: string[] = []
2697
+ let form = false
2698
+ let format: SubsumeFormat = 'text'
2699
+
2700
+ for (let i = 0; i < argv.length; i++) {
2701
+ const arg = argv[i]
2702
+ if ('-h' === arg || '--help' === arg) {
2703
+ process.stdout.write(HELP)
2704
+ return 0
2705
+ }
2706
+ if ('--form' === arg) {
2707
+ form = true
2708
+ }
2709
+ else if ('--format' === arg) {
2710
+ const f = argv[++i]
2711
+ if ('text' !== f && 'json' !== f) {
2712
+ process.stderr.write('aontu: --format needs text or json\n')
2713
+ return 2
2714
+ }
2715
+ format = f
2716
+ }
2717
+ else if (arg.startsWith('-')) {
2718
+ process.stderr.write(`aontu: unknown hash option ${arg} (try --help)\n`)
2719
+ return 2
2720
+ }
2721
+ else {
2722
+ files.push(arg)
2723
+ }
2724
+ }
2725
+
2726
+ if (1 !== files.length) {
2727
+ process.stderr.write(`aontu: hash needs one file\n${HASH_HELP}\n`)
2728
+ return 2
2729
+ }
2730
+
2731
+ let src: string
2732
+ try {
2733
+ src = readFileSync(files[0], 'utf8')
2734
+ }
2735
+ catch (err: any) {
2736
+ process.stderr.write(`aontu: cannot read ${err.path}: ${err.message}\n`)
2737
+ return 2
2738
+ }
2739
+
2740
+ // The file's own directory is the include base, as every verb
2741
+ // resolves a named file (vet's aontuForPath rule).
2742
+ const capability = verbTrust(trust, entryRootOf(files[0]))
2743
+ const aontu = new Aontu(
2744
+ null == capability ? undefined : { trust: capability })
2745
+ const ctx = aontu.ctx({ collect: true })
2746
+ const v: any = aontu.unify(src, { path: files[0] }, ctx)
2747
+ if (0 < ctx.err.length || true === v?.isNil) {
2748
+ // A document that does not stand up on its own has no meaning to
2749
+ // pin, and a hash of a broken evaluation would be a pin that
2750
+ // silently agrees with every other broken evaluation.
2751
+ // WHY it does not stand up, not just that it does not: the same
2752
+ // diagnosis `aontu <file>` prints (the review's finding F).
2753
+ // evalFailure unconditionally, as every other call site does: it
2754
+ // owns the "ctx.err is never empty here" contract, and a guard
2755
+ // that pretends otherwise is a dead arm asserting nothing.
2756
+ process.stderr.write(
2757
+ `aontu: ${files[0]} does not evaluate on its own; nothing to hash\n` +
2758
+ renderFinding(evalFailure(ctx)) + '\n')
2759
+ return 4
2760
+ }
2761
+
2762
+ const text = 'json' === format
2763
+ ? exactJSON({
2764
+ aontu: { version: version(), verb: 'hash' },
2765
+ hash: canonHash(v),
2766
+ form: hcanon(v),
2767
+ }, 2)
2768
+ : (form ? hcanon(v) : canonHash(v))
2769
+ process.stdout.write(text + '\n')
2770
+ return 0
2771
+ }
2772
+
2773
+
2774
+ // ---------------------------------------------------------------------
2775
+ // The query surface (G7 phase 1): one node of an evaluated document,
2776
+ // selected by path and rendered. Evaluation is still GLOBAL -- what
2777
+ // `get` buys is the size of the ANSWER, not the cost of producing it --
2778
+ // and the projections are lattice abstractions, each a valid Aontu
2779
+ // document that subsumes the truth it summarises.
2780
+
2781
+ const GET_HELP = 'aontu get <path> <file> (try --help)'
2782
+
2783
+ function runGet(argv: string[]): number {
2784
+ const trusted = takeTrust(argv)
2785
+ if (null == trusted) {
2786
+ return 2
2787
+ }
2788
+ argv = trusted.argv
2789
+ const trust = trusted.trust
2790
+ const rest: string[] = []
2791
+ let view: QueryView = 'json'
2792
+ let depth: number | undefined
2793
+ let format: SubsumeFormat = 'text'
2794
+
2795
+ for (let i = 0; i < argv.length; i++) {
2796
+ const arg = argv[i]
2797
+ if ('-h' === arg || '--help' === arg) {
2798
+ process.stdout.write(HELP)
2799
+ return 0
2800
+ }
2801
+ if ('-c' === arg || '--canon' === arg) {
2802
+ view = 'canon'
2803
+ }
2804
+ else if ('--keys' === arg) {
2805
+ view = 'keys'
2806
+ }
2807
+ else if ('--types' === arg) {
2808
+ view = 'types'
2809
+ }
2810
+ else if ('--depth' === arg) {
2811
+ const n = Number(argv[++i])
2812
+ if (!Number.isInteger(n) || n < 1) {
2813
+ process.stderr.write('aontu: --depth needs a positive integer\n')
2814
+ return 2
2815
+ }
2816
+ depth = n
2817
+ }
2818
+ else if ('--format' === arg) {
2819
+ const f = argv[++i]
2820
+ if ('text' !== f && 'json' !== f) {
2821
+ process.stderr.write('aontu: --format needs text or json\n')
2822
+ return 2
2823
+ }
2824
+ format = f
2825
+ }
2826
+ else if (arg.startsWith('-')) {
2827
+ process.stderr.write(`aontu: unknown get option ${arg} (try --help)\n`)
2828
+ return 2
2829
+ }
2830
+ else {
2831
+ rest.push(arg)
2832
+ }
2833
+ }
2834
+
2835
+ if (2 !== rest.length) {
2836
+ process.stderr.write(`aontu: get needs a path and one file\n${GET_HELP}\n`)
2837
+ return 2
2838
+ }
2839
+ const [path, file] = rest
2840
+
2841
+ // ELIDING BELOW A DEPTH means rendering `top`, which JSON cannot
2842
+ // say. Rather than switch the view silently -- the choice `trim
2843
+ // --check` refused to make -- the combination is a usage error.
2844
+ if (null != depth && 'canon' !== view && 'types' !== view) {
2845
+ process.stderr.write(
2846
+ 'aontu: --depth needs --canon or --types (JSON cannot say top)\n')
2847
+ return 2
2848
+ }
2849
+
2850
+ let src: string
2851
+ try {
2852
+ src = readFileSync(file, 'utf8')
2853
+ }
2854
+ catch (err: any) {
2855
+ process.stderr.write(`aontu: cannot read ${err.path}: ${err.message}\n`)
2856
+ return 2
2857
+ }
2858
+
2859
+ const report = get(src, path, {
2860
+ view, depth, path: file, trust: verbTrust(trust, entryRootOf(file)),
2861
+ })
2862
+ if ('json' === format) {
2863
+ process.stdout.write(exactJSON({
2864
+ aontu: { version: version(), verb: 'get' },
2865
+ findings: report.findings,
2866
+ ok: report.ok,
2867
+ out: report.out,
2868
+ }, 2) + '\n')
2869
+ }
2870
+ else if (report.ok) {
2871
+ process.stdout.write(report.out + '\n')
2872
+ }
2873
+ else {
2874
+ process.stderr.write(report.findings.map(renderFinding).join('\n') + '\n')
2875
+ }
2876
+
2877
+ if (report.ok) {
2878
+ return 0
2879
+ }
2880
+ // A path that names nothing is the QUESTION's answer -- exit 1, the
2881
+ // "no" class -- while a document that does not stand up is exit 4,
2882
+ // as it is for every other verb.
2883
+ return 'no_path' === report.findings[0]?.code ? 1 : 4
2884
+ }
2885
+
2886
+
2887
+ // ---------------------------------------------------------------------
2888
+ // Provenance (G7 phase 3): WHY the value at a path holds — the ordered
2889
+ // contributions that met there, each with the site it was written at.
2890
+ // The positive twin of the vet report: errors explain what failed to
2891
+ // unify, this explains what did.
2892
+
2893
+ const WHY_HELP = 'aontu why <path> <file> (try --help)'
2894
+
2895
+ function runWhy(argv: string[]): number {
2896
+ const trusted = takeTrust(argv)
2897
+ if (null == trusted) {
2898
+ return 2
2899
+ }
2900
+ argv = trusted.argv
2901
+ const trust = trusted.trust
2902
+ const rest: string[] = []
2903
+ let format: SubsumeFormat = 'text'
2904
+
2905
+ for (let i = 0; i < argv.length; i++) {
2906
+ const arg = argv[i]
2907
+ if ('-h' === arg || '--help' === arg) {
2908
+ process.stdout.write(HELP)
2909
+ return 0
2910
+ }
2911
+ if ('--format' === arg) {
2912
+ const f = argv[++i]
2913
+ if ('text' !== f && 'json' !== f) {
2914
+ process.stderr.write('aontu: --format needs text or json\n')
2915
+ return 2
2916
+ }
2917
+ format = f
2918
+ }
2919
+ else if (arg.startsWith('-')) {
2920
+ process.stderr.write(`aontu: unknown why option ${arg} (try --help)\n`)
2921
+ return 2
2922
+ }
2923
+ else {
2924
+ rest.push(arg)
2925
+ }
2926
+ }
2927
+
2928
+ if (2 !== rest.length) {
2929
+ process.stderr.write(`aontu: why needs a path and one file\n${WHY_HELP}\n`)
2930
+ return 2
2931
+ }
2932
+ const [path, file] = rest
2933
+
2934
+ let src: string
2935
+ try {
2936
+ src = readFileSync(file, 'utf8')
2937
+ }
2938
+ catch (err: any) {
2939
+ process.stderr.write(`aontu: cannot read ${err.path}: ${err.message}\n`)
2940
+ return 2
2941
+ }
2942
+
2943
+ const report = why(src, path, {
2944
+ path: file, trust: verbTrust(trust, entryRootOf(file)),
2945
+ })
2946
+ if ('json' === format) {
2947
+ process.stdout.write(exactJSON({
2948
+ aontu: { version: version(), verb: 'why' },
2949
+ findings: report.findings,
2950
+ ok: report.ok,
2951
+ ...(null == report.record ? {} : { record: report.record }),
2952
+ }, 2) + '\n')
2953
+ }
2954
+ else if (report.ok) {
2955
+ process.stdout.write(renderWhyText(report.record as WhyRecord) + '\n')
2956
+ }
2957
+ else {
2958
+ process.stderr.write(report.findings.map(renderFinding).join('\n') + '\n')
2959
+ }
2960
+
2961
+ if (report.ok) {
2962
+ return 0
2963
+ }
2964
+ return 'no_path' === report.findings[0]?.code ? 1 : 4
2965
+ }
2966
+
2967
+
2968
+ // One contribution per line, numbered in source order, each with what
2969
+ // was written, where, and how it got here. A siteless contribution
2970
+ // prints no location rather than a `-1:-1` that means nothing —
2971
+ // exported for the direct test, because the site SHAPE allows one
2972
+ // while no document has yet produced one (ADR-002).
2973
+ function renderWhyText(record: WhyRecord): string {
2974
+ const head = `${record.path} = ${record.value}`
2975
+ if (0 === record.conjuncts.length) {
2976
+ // A value written once and never met is a fact, not a failure.
2977
+ return head + '\n (no contributions: nothing met at this path)'
2978
+ }
2979
+ return [head].concat(record.conjuncts.map((c, i) => {
2980
+ const where = -1 === c.site.row
2981
+ ? ''
2982
+ : ` ${'' === c.site.file ? '' : c.site.file + ':'}` +
2983
+ `${c.site.row}:${c.site.col}`
2984
+ return ` ${i + 1}. ${c.canon}${where}` +
2985
+ ('literal' === c.role ? '' : ` (${c.role})`)
2986
+ })).join('\n')
2987
+ }
2988
+
2989
+
2990
+ // ---------------------------------------------------------------------
2991
+ // The overlay patch verb (G7 phase 5): change a document by APPENDING
2992
+ // to an overlay, not by rewriting it. An overlay entry is just another
2993
+ // conjunct and unification is order-independent, so this needs no
2994
+ // rewriter — the format-preserving in-place edit is stage 2, and needs
2995
+ // a comment-preserving CST the parser stack does not have.
2996
+
2997
+ const SET_HELP =
2998
+ 'aontu set <path>=<value> --entry <file> --overlay <file> (try --help)'
2999
+
3000
+ function runSet(argv: string[]): number {
3001
+ const trusted = takeTrust(argv)
3002
+ if (null == trusted) {
3003
+ return 2
3004
+ }
3005
+ argv = trusted.argv
3006
+ const trust = trusted.trust
3007
+ const assignments: string[] = []
3008
+ let entry: string | undefined
3009
+ let overlayFile: string | undefined
3010
+ let dryRun = false
3011
+ let inPlace = false
3012
+ let format: SubsumeFormat = 'text'
3013
+
3014
+ for (let i = 0; i < argv.length; i++) {
3015
+ const arg = argv[i]
3016
+ if ('-h' === arg || '--help' === arg) {
3017
+ process.stdout.write(HELP)
3018
+ return 0
3019
+ }
3020
+ if ('--entry' === arg) {
3021
+ entry = argv[++i]
3022
+ }
3023
+ else if ('--overlay' === arg) {
3024
+ overlayFile = argv[++i]
3025
+ }
3026
+ else if ('--dry-run' === arg) {
3027
+ dryRun = true
3028
+ }
3029
+ else if ('--in-place' === arg) {
3030
+ inPlace = true
3031
+ }
3032
+ else if ('--format' === arg) {
3033
+ const f = argv[++i]
3034
+ if ('text' !== f && 'json' !== f) {
3035
+ process.stderr.write('aontu: --format needs text or json\n')
3036
+ return 2
3037
+ }
3038
+ format = f
3039
+ }
3040
+ else if (arg.startsWith('-')) {
3041
+ process.stderr.write(`aontu: unknown set option ${arg} (try --help)\n`)
3042
+ return 2
3043
+ }
3044
+ else {
3045
+ assignments.push(arg)
3046
+ }
3047
+ }
3048
+
3049
+ if (0 === assignments.length || null == entry || null == overlayFile) {
3050
+ process.stderr.write(
3051
+ `aontu: set needs assignments, --entry and --overlay\n${SET_HELP}\n`)
3052
+ return 2
3053
+ }
3054
+
3055
+ let entrySrc: string
3056
+ try {
3057
+ entrySrc = readFileSync(entry, 'utf8')
3058
+ }
3059
+ catch (err: any) {
3060
+ process.stderr.write(`aontu: cannot read ${err.path}: ${err.message}\n`)
3061
+ return 2
3062
+ }
3063
+
3064
+ // An ABSENT overlay is the empty overlay, and the file is created by
3065
+ // the write below: "append to the overlay" should not require the
3066
+ // author to have made one first.
3067
+ let overlaySrc = ''
3068
+ try {
3069
+ overlaySrc = readFileSync(overlayFile, 'utf8')
3070
+ }
3071
+ catch (err: any) {
3072
+ if ('ENOENT' !== err?.code) {
3073
+ process.stderr.write(`aontu: cannot read ${err.path}: ${err.message}\n`)
3074
+ return 2
3075
+ }
3076
+ }
3077
+
3078
+ const report = patch(entrySrc, overlaySrc, assignments, {
3079
+ trust: verbTrust(trust, entryRootOf(entry)),
3080
+ entryPath: entry,
3081
+ overlayPath: overlayFile,
3082
+ inPlace,
3083
+ })
3084
+
3085
+ // WRITTEN ONLY WHEN IT HOLDS. A change that contradicts a pinned
3086
+ // value is a question the author has to answer at the pinning site;
3087
+ // leaving it in the overlay would leave the configuration broken
3088
+ // while the exit code says so somewhere they may not be reading.
3089
+ const wrote = !dryRun &&
3090
+ 'invalid' !== report.verdict && 'error' !== report.verdict
3091
+ if (wrote) {
3092
+ try {
3093
+ writeFileSync(overlayFile, report.overlay, 'utf8')
3094
+ }
3095
+ catch (err: any) {
3096
+ process.stderr.write(`aontu: cannot write ${overlayFile}: ${err.message}\n`)
3097
+ return 2
3098
+ }
3099
+ }
3100
+
3101
+ if ('json' === format) {
3102
+ process.stdout.write(exactJSON({
3103
+ aontu: { version: version(), verb: 'set' },
3104
+ appended: report.appended,
3105
+ findings: report.findings,
3106
+ overlay: report.overlay,
3107
+ replaced: report.replaced,
3108
+ verdict: report.verdict,
3109
+ written: wrote,
3110
+ }, 2) + '\n')
3111
+ }
3112
+ else {
3113
+ // A replacement is REPORTED as the edit it is, not left for the
3114
+ // reader to infer from a changed file: `where: what -> what`, in
3115
+ // source spelling, because the spelling is what changed.
3116
+ //
3117
+ // PAST TENSE ONLY WHERE IT HAPPENED. A refused write leaves the
3118
+ // file exactly as it was, and one assignment can be replaceable
3119
+ // while another makes the whole run invalid — so `replaced:` there
3120
+ // tells an operator the pin was changed when it was not, and unlike
3121
+ // `--dry-run` there is nothing else on the line to say otherwise.
3122
+ const verb = wrote ? 'replaced' : 'would replace'
3123
+ const edits = report.replaced.map((r) =>
3124
+ `${verb}: ${r.file}:${r.row}:${r.col} ${r.from} -> ${r.to}`)
3125
+ const head = [`verdict: ${report.verdict}`].concat(edits).join('\n') +
3126
+ (wrote ? `\nwrote: ${overlayFile}` : dryRun ? '\n(dry run)' : '')
3127
+
3128
+ // A SUCCESSFUL COMMAND WRITES ITS STATUS TO STDOUT, findings or
3129
+ // not. Routing on `findings.length` was right while every finding
3130
+ // this verb could produce was an ERROR; `--in-place` made a WARNING
3131
+ // possible, and a run that held, wrote the file and exited 0 then
3132
+ // sent its whole report to stderr — leaving stdout empty, so
3133
+ // `$(aontu set ...)` captured nothing and only the JSON form
3134
+ // behaved like a success. The verdict decides the stream; warnings
3135
+ // are diagnostics and go to stderr beside it.
3136
+ const failed = 'invalid' === report.verdict || 'error' === report.verdict
3137
+ const findingText = report.findings.map(renderFinding)
3138
+ if (failed) {
3139
+ // A FAILED VERDICT ALWAYS CARRIES A FINDING — the conflict, or
3140
+ // the parse error, that made it fail — so the blank separator is
3141
+ // unconditional. Guarding it described a report vet cannot
3142
+ // produce, and the coverage gate said so.
3143
+ process.stderr.write([head, ''].concat(findingText).join('\n') + '\n')
3144
+ }
3145
+ else {
3146
+ process.stdout.write(head + '\n')
3147
+ if (0 < findingText.length) {
3148
+ process.stderr.write(findingText.join('\n') + '\n')
3149
+ }
3150
+ }
3151
+ }
3152
+
3153
+ return VET_EXIT[report.verdict]
3154
+ }
3155
+
3156
+
3157
+ // ---------------------------------------------------------------------
3158
+ // The generated AGENTS.md stanza (G7 phase 6): the prose entrypoint,
3159
+ // derived from the definition, so it cannot drift from the formal
3160
+ // source it points at.
3161
+
3162
+ const AGENTSMD_HELP = 'aontu agentsmd <file> (try --help)'
3163
+
3164
+ function runAgentsMd(argv: string[]): number {
3165
+ const trusted = takeTrust(argv)
3166
+ if (null == trusted) {
3167
+ return 2
3168
+ }
3169
+ argv = trusted.argv
3170
+ const trust = trusted.trust
3171
+ const files: string[] = []
3172
+ let write: string | undefined
3173
+
3174
+ for (let i = 0; i < argv.length; i++) {
3175
+ const arg = argv[i]
3176
+ if ('-h' === arg || '--help' === arg) {
3177
+ process.stdout.write(HELP)
3178
+ return 0
3179
+ }
3180
+ if ('--write' === arg) {
3181
+ write = argv[++i]
3182
+ if (null == write) {
3183
+ process.stderr.write('aontu: --write needs a file\n')
3184
+ return 2
3185
+ }
3186
+ }
3187
+ else if (arg.startsWith('-')) {
3188
+ process.stderr.write(
3189
+ `aontu: unknown agentsmd option ${arg} (try --help)\n`)
3190
+ return 2
3191
+ }
3192
+ else {
3193
+ files.push(arg)
3194
+ }
3195
+ }
3196
+
3197
+ if (1 !== files.length) {
3198
+ process.stderr.write(
3199
+ `aontu: agentsmd needs one file\n${AGENTSMD_HELP}\n`)
3200
+ return 2
3201
+ }
3202
+
3203
+ let src: string
3204
+ try {
3205
+ src = readFileSync(files[0], 'utf8')
3206
+ }
3207
+ catch (err: any) {
3208
+ process.stderr.write(`aontu: cannot read ${err.path}: ${err.message}\n`)
3209
+ return 2
3210
+ }
3211
+
3212
+ const report = agentsMd(src, {
3213
+ name: files[0], path: files[0],
3214
+ trust: verbTrust(trust, entryRootOf(files[0])),
3215
+ })
3216
+ if (!report.ok) {
3217
+ process.stderr.write(
3218
+ report.findings.map(renderFinding).join('\n') + '\n')
3219
+ return 4
3220
+ }
3221
+
3222
+ if (null == write) {
3223
+ process.stdout.write(report.stanza)
3224
+ return 0
3225
+ }
3226
+
3227
+ // An ABSENT target is an empty one: `--write AGENTS.md` should not
3228
+ // require the author to have made the file first.
3229
+ let existing = ''
3230
+ try {
3231
+ existing = readFileSync(write, 'utf8')
3232
+ }
3233
+ catch (err: any) {
3234
+ if ('ENOENT' !== err?.code) {
3235
+ process.stderr.write(`aontu: cannot read ${err.path}: ${err.message}\n`)
3236
+ return 2
3237
+ }
3238
+ }
3239
+
3240
+ try {
3241
+ writeFileSync(write, agentsMdSplice(existing, report.stanza), 'utf8')
3242
+ }
3243
+ catch (err: any) {
3244
+ process.stderr.write(`aontu: cannot write ${write}: ${err.message}\n`)
3245
+ return 2
3246
+ }
3247
+ process.stdout.write(`wrote: ${write}\n`)
3248
+ return 0
3249
+ }
3250
+
3251
+
3252
+ // Exit without truncating output.
3253
+ //
3254
+ // process.exit() terminates immediately, discarding anything still
3255
+ // queued on stdout. A write to a PIPE is asynchronous once it exceeds
3256
+ // the pipe buffer, so `write(big); exit(0)` silently truncated output at
3257
+ // 65536 bytes — while a write to a TTY or a file, being synchronous,
3258
+ // looked fine. Setting exitCode instead lets the process end naturally,
3259
+ // after the queue drains.
3260
+ //
3261
+ // This predates the exact leaves but they make it trivially reachable
3262
+ // (one long biginteger canon exceeds the buffer), and it lands squarely
3263
+ // on the parity-probe discipline in AGENTS.md, which derives expected
3264
+ // spec values by piping BOTH CLIs and comparing. A truncated pipe there
3265
+ // reads as a port divergence.
3266
+ function finish(code: number): void {
3267
+ process.exitCode = code
3268
+ }
3269
+
3270
+
3271
+ // Parse a --trust argument value. Returns undefined for an unknown
3272
+ // spelling, so the caller owns the usage error.
3273
+ function parseTrustArg(value: string): TrustArg | undefined {
3274
+ if ('system' === value) {
3275
+ return { kind: 'system' }
3276
+ }
3277
+ if ('none' === value) {
3278
+ return { kind: 'none' }
3279
+ }
3280
+ if ('root' === value) {
3281
+ return { kind: 'root' }
3282
+ }
3283
+ if (value.startsWith('root:') && 'root:'.length < value.length) {
3284
+ return { kind: 'root', dir: value.slice('root:'.length) }
3285
+ }
3286
+ return undefined
3287
+ }
3288
+
3289
+
3290
+ function main(argv: string[]): void {
3291
+ // COLOUR OFF WHEN THE DESTINATION IS NOT A TERMINAL. Error frames
3292
+ // hardcoded their ANSI escapes, so a piped report and a `--jsonl`
3293
+ // answer carried terminal control codes into whatever read them (the
3294
+ // review's finding F). `NO_COLOR` is honoured by the library itself;
3295
+ // only the CLI can see whether its stderr is a terminal, so only the
3296
+ // CLI can make this call. `undefined` means "leave it to NO_COLOR".
3297
+ setColor(true === process.stderr.isTTY ? undefined : false)
3298
+
3299
+ let mode: Mode = 'json'
3300
+ // A LIST, though the bare command evaluates exactly one document.
3301
+ // It used to be one variable and the last argument won, which made a
3302
+ // MISTYPED VERB a silent success: `aontu vet2 schema.aon good.json`
3303
+ // printed good.json and exited 0, because `vet2` matched no
3304
+ // subcommand, fell through to this loop as a file name, and was
3305
+ // overwritten twice. In a tool loop that reads as a passing
3306
+ // validation. Counting them is what lets the refusal below happen.
3307
+ const files: string[] = []
3308
+ let trust: TrustArg = { kind: 'system-warn' }
3309
+ // The REPL's SESSION protocol (G7 phase 7): one JSON line per
3310
+ // answer, so a harness can drive the session. Named --jsonl rather
3311
+ // than the design's --json, which would read as the `:json` output
3312
+ // mode the REPL already has.
3313
+ let jsonl = false
3314
+
3315
+ // Subcommand dispatch, and deliberately only for a FIRST argument:
3316
+ // `aontu vet` is the verb, while `aontu somefile vet` keeps meaning
3317
+ // what it always did. A file named `vet` is still reachable as
3318
+ // `aontu ./vet`.
3319
+ //
3320
+ // Promise.resolve either way: a non-watch run returns its exit class
3321
+ // synchronously (and has already written its report), while `--watch`
3322
+ // resolves only when the watch ends — so one await-shaped line serves
3323
+ // both without a branch to keep covered.
3324
+ if ('vet' === argv[2]) {
3325
+ return void Promise.resolve(runVet(argv.slice(3))).then(finish)
3326
+ }
3327
+ if ('subsume' === argv[2]) {
3328
+ return finish(runSubsume(argv.slice(3)))
3329
+ }
3330
+ if ('breaking' === argv[2]) {
3331
+ return finish(runBreaking(argv.slice(3)))
3332
+ }
3333
+ if ('agentsmd' === argv[2]) {
3334
+ return finish(runAgentsMd(argv.slice(3)))
3335
+ }
3336
+
3337
+ if ('set' === argv[2]) {
3338
+ return finish(runSet(argv.slice(3)))
3339
+ }
3340
+
3341
+ if ('why' === argv[2]) {
3342
+ return finish(runWhy(argv.slice(3)))
3343
+ }
3344
+
3345
+ if ('get' === argv[2]) {
3346
+ return finish(runGet(argv.slice(3)))
3347
+ }
3348
+
3349
+ if ('hash' === argv[2]) {
3350
+ return finish(runHash(argv.slice(3)))
3351
+ }
3352
+
3353
+ if ('mod' === argv[2]) {
3354
+ return finish(runMod(argv.slice(3)))
3355
+ }
3356
+
3357
+ if ('relations' === argv[2]) {
3358
+ return finish(runRelations(argv.slice(3)))
3359
+ }
3360
+
3361
+ if ('jsonschema' === argv[2]) {
3362
+ return finish(runJsonSchema(argv.slice(3)))
3363
+ }
3364
+
3365
+ if ('reaches' === argv[2]) {
3366
+ return finish(runReaches(argv.slice(3)))
3367
+ }
3368
+
3369
+ if ('view' === argv[2]) {
3370
+ return finish(runView(argv.slice(3)))
3371
+ }
3372
+
3373
+ if ('trim' === argv[2]) {
3374
+ return finish(runTrim(argv.slice(3)))
3375
+ }
3376
+
3377
+ const args = argv.slice(2)
3378
+ for (let i = 0; i < args.length; i++) {
3379
+ const arg = args[i]
3380
+ if ('-c' === arg || '--canon' === arg) {
3381
+ mode = 'canon'
3382
+ }
3383
+ else if ('-h' === arg || '--help' === arg) {
3384
+ process.stdout.write(HELP)
3385
+ return finish(0)
3386
+ }
3387
+ else if ('-v' === arg || '--version' === arg) {
3388
+ process.stdout.write(version() + '\n')
3389
+ return finish(0)
3390
+ }
3391
+ else if ('--trust' === arg) {
3392
+ const parsed = null == args[i + 1] ? undefined : parseTrustArg(args[++i])
3393
+ if (null == parsed) {
3394
+ process.stderr.write(
3395
+ 'aontu: --trust needs system, none, or root[:dir]\n')
3396
+ return finish(2)
3397
+ }
3398
+ trust = parsed
3399
+ }
3400
+ else if ('--jsonl' === arg) {
3401
+ jsonl = true
3402
+ // A JSONL answer is machine-read by definition, even when the
3403
+ // session happens to be attached to a terminal, so this is a
3404
+ // harder gate than the stderr test above rather than a repeat of
3405
+ // it: escapes inside the answer string are noise the harness has
3406
+ // to strip before it can compare anything.
3407
+ setColor(false)
3408
+ }
3409
+ else if ('--include-root' === arg) {
3410
+ const dir = args[++i]
3411
+ if (null == dir) {
3412
+ process.stderr.write('aontu: --include-root needs a directory\n')
3413
+ return finish(2)
3414
+ }
3415
+ trust = { kind: 'root', dir }
3416
+ }
3417
+ else if (arg.startsWith('-')) {
3418
+ process.stderr.write(`aontu: unknown option ${arg} (try --help)\n`)
3419
+ return finish(2)
3420
+ }
3421
+ else {
3422
+ files.push(arg)
3423
+ }
3424
+ }
3425
+
3426
+ // ONE DOCUMENT. The bare form has always been `aontu [options]
3427
+ // [file]`, singular, and anything past the first was silently
3428
+ // discarded rather than refused -- so every way of getting the verb
3429
+ // wrong (a typo, a verb this port does not have, a verb spelled for
3430
+ // another tool) ended in a plausible answer about the wrong file.
3431
+ // Exit 2, the usage class, and the message names the cause rather
3432
+ // than the symptom: nothing here can tell a mistyped verb from a
3433
+ // second file, but the reader can.
3434
+ if (1 < files.length) {
3435
+ process.stderr.write(
3436
+ `aontu: the bare command evaluates one document, and ${files.length}` +
3437
+ ' were given\naontu: a mistyped verb reads as a file name' +
3438
+ ' (try --help)\n')
3439
+ return finish(2)
3440
+ }
3441
+
3442
+ const file = files[0]
3443
+ if (null != file) {
3444
+ finish(runFile(file, mode, trust))
3445
+ }
3446
+ // `--jsonl` overrides the TTY gate: the mode exists to be DRIVEN by
3447
+ // a harness over a pipe, so gating it on an interactive terminal
3448
+ // made it reachable only through a pty -- which is to say, not
3449
+ // reachable by the thing it was built for. Mirrors go/cmd/aontu.
3450
+ else if (jsonl || process.stdin.isTTY) {
3451
+ runRepl(mode, jsonl, trust)
3452
+ }
3453
+ else {
3454
+ runStdin(mode, trust).then((code) => finish(code))
3455
+ }
3456
+ } /* node:coverage ignore next 16 */
3457
+
3458
+
3459
+ // No require.main guard here: bin/aontu.js is the executable entry and
3460
+ // calls main(process.argv) itself, so this module stays import-only.
3461
+
3462
+
3463
+ export {
3464
+ evalSource, main, runVet, runSubsume, runBreaking, runTrim, runRelations,
3465
+ runReaches,
3466
+ runView,
3467
+ runJsonSchema,
3468
+ runMod,
3469
+ runHash, runGet,
3470
+ runWhy, renderWhyText, runSet, runAgentsMd,
3471
+ watchChange, watchSignature, vetWaiter, deprecatedAt,
3472
+ }