aontu 0.61.0 → 0.63.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 (371) hide show
  1. package/README.md +4 -4
  2. package/dist/agentsmd.d.ts +1 -0
  3. package/dist/agentsmd.js +7 -28
  4. package/dist/agentsmd.js.map +1 -1
  5. package/dist/alias.js.map +1 -1
  6. package/dist/allow.d.ts +23 -0
  7. package/dist/allow.js +138 -0
  8. package/dist/allow.js.map +1 -0
  9. package/dist/aontu.d.ts +4 -2
  10. package/dist/aontu.js +4 -80
  11. package/dist/aontu.js.map +1 -1
  12. package/dist/aontumodel.d.ts +4 -0
  13. package/dist/aontumodel.js +33 -0
  14. package/dist/aontumodel.js.map +1 -0
  15. package/dist/cli.d.ts +10 -1
  16. package/dist/cli.js +877 -479
  17. package/dist/cli.js.map +1 -1
  18. package/dist/ctx.js +0 -48
  19. package/dist/ctx.js.map +1 -1
  20. package/dist/diff.js +0 -32
  21. package/dist/diff.js.map +1 -1
  22. package/dist/err.js +0 -40
  23. package/dist/err.js.map +1 -1
  24. package/dist/escape.js +0 -45
  25. package/dist/escape.js.map +1 -1
  26. package/dist/exactjson.d.ts +0 -35
  27. package/dist/exactjson.js +0 -131
  28. package/dist/exactjson.js.map +1 -1
  29. package/dist/format.js +55 -189
  30. package/dist/format.js.map +1 -1
  31. package/dist/grammar.d.ts +9 -0
  32. package/dist/grammar.js +54 -0
  33. package/dist/grammar.js.map +1 -0
  34. package/dist/graph.js +0 -26
  35. package/dist/graph.js.map +1 -1
  36. package/dist/hcanon.js +0 -82
  37. package/dist/hcanon.js.map +1 -1
  38. package/dist/helpdoc.d.ts +16 -0
  39. package/dist/helpdoc.js +59 -0
  40. package/dist/helpdoc.js.map +1 -0
  41. package/dist/hints.d.ts +0 -6
  42. package/dist/hints.js +59 -47
  43. package/dist/hints.js.map +1 -1
  44. package/dist/jsonschema.js +0 -114
  45. package/dist/jsonschema.js.map +1 -1
  46. package/dist/keyorder.d.ts +0 -7
  47. package/dist/keyorder.js +0 -41
  48. package/dist/keyorder.js.map +1 -1
  49. package/dist/lang.js +32 -877
  50. package/dist/lang.js.map +1 -1
  51. package/dist/lower.d.ts +3 -0
  52. package/dist/lower.js +14 -61
  53. package/dist/lower.js.map +1 -1
  54. package/dist/lsp-server.js +0 -16
  55. package/dist/lsp-server.js.map +1 -1
  56. package/dist/lsp.d.ts +1 -1
  57. package/dist/lsp.js +12 -159
  58. package/dist/lsp.js.map +1 -1
  59. package/dist/mcp-server.js +0 -26
  60. package/dist/mcp-server.js.map +1 -1
  61. package/dist/mcp.js +0 -113
  62. package/dist/mcp.js.map +1 -1
  63. package/dist/mod-tool.js +0 -130
  64. package/dist/mod-tool.js.map +1 -1
  65. package/dist/mod.js +0 -162
  66. package/dist/mod.js.map +1 -1
  67. package/dist/patch.js +0 -217
  68. package/dist/patch.js.map +1 -1
  69. package/dist/provenance.js +0 -140
  70. package/dist/provenance.js.map +1 -1
  71. package/dist/query.js +0 -75
  72. package/dist/query.js.map +1 -1
  73. package/dist/reach.js +0 -43
  74. package/dist/reach.js.map +1 -1
  75. package/dist/relation.d.ts +2 -0
  76. package/dist/relation.js +3 -58
  77. package/dist/relation.js.map +1 -1
  78. package/dist/render.js +33 -143
  79. package/dist/render.js.map +1 -1
  80. package/dist/report-sarif.d.ts +0 -11
  81. package/dist/report-sarif.js +0 -28
  82. package/dist/report-sarif.js.map +1 -1
  83. package/dist/sig.js +0 -35
  84. package/dist/sig.js.map +1 -1
  85. package/dist/sigdecl.js +1 -1
  86. package/dist/sigdecl.js.map +1 -1
  87. package/dist/siggate.js +0 -4
  88. package/dist/siggate.js.map +1 -1
  89. package/dist/site.js +3 -29
  90. package/dist/site.js.map +1 -1
  91. package/dist/subsume.d.ts +0 -10
  92. package/dist/subsume.js +0 -137
  93. package/dist/subsume.js.map +1 -1
  94. package/dist/template.d.ts +2 -1
  95. package/dist/template.js +58 -138
  96. package/dist/template.js.map +1 -1
  97. package/dist/trim.js +0 -41
  98. package/dist/trim.js.map +1 -1
  99. package/dist/tsconfig.tsbuildinfo +1 -1
  100. package/dist/type.js.map +1 -1
  101. package/dist/unify.js +12 -242
  102. package/dist/unify.js.map +1 -1
  103. package/dist/utility.js +0 -22
  104. package/dist/utility.js.map +1 -1
  105. package/dist/val/AbnfFuncVal.d.ts +18 -0
  106. package/dist/val/AbnfFuncVal.js +132 -0
  107. package/dist/val/AbnfFuncVal.js.map +1 -0
  108. package/dist/val/AbsentVal.d.ts +11 -0
  109. package/dist/val/AbsentVal.js +30 -0
  110. package/dist/val/AbsentVal.js.map +1 -0
  111. package/dist/val/AggFuncVal.d.ts +10 -1
  112. package/dist/val/AggFuncVal.js +104 -116
  113. package/dist/val/AggFuncVal.js.map +1 -1
  114. package/dist/val/ArithFuncVal.js +0 -12
  115. package/dist/val/ArithFuncVal.js.map +1 -1
  116. package/dist/val/BagVal.js +1 -78
  117. package/dist/val/BagVal.js.map +1 -1
  118. package/dist/val/BigDecimalVal.js +0 -16
  119. package/dist/val/BigDecimalVal.js.map +1 -1
  120. package/dist/val/BigIntegerVal.js +0 -16
  121. package/dist/val/BigIntegerVal.js.map +1 -1
  122. package/dist/val/CloseFuncVal.js +0 -9
  123. package/dist/val/CloseFuncVal.js.map +1 -1
  124. package/dist/val/CmpFuncVal.d.ts +20 -0
  125. package/dist/val/CmpFuncVal.js +188 -0
  126. package/dist/val/CmpFuncVal.js.map +1 -0
  127. package/dist/val/ConjunctVal.js +0 -29
  128. package/dist/val/ConjunctVal.js.map +1 -1
  129. package/dist/val/ConstraintVal.js +0 -500
  130. package/dist/val/ConstraintVal.js.map +1 -1
  131. package/dist/val/ContainerKindVal.js +0 -2
  132. package/dist/val/ContainerKindVal.js.map +1 -1
  133. package/dist/val/CopyFuncVal.js +0 -3
  134. package/dist/val/CopyFuncVal.js.map +1 -1
  135. package/dist/val/Decimal.js +0 -179
  136. package/dist/val/Decimal.js.map +1 -1
  137. package/dist/val/DeprecateFuncVal.js.map +1 -1
  138. package/dist/val/DisjunctVal.js +0 -152
  139. package/dist/val/DisjunctVal.js.map +1 -1
  140. package/dist/val/EachFuncVal.d.ts +1 -2
  141. package/dist/val/EachFuncVal.js +12 -29
  142. package/dist/val/EachFuncVal.js.map +1 -1
  143. package/dist/val/EmitFuncVal.d.ts +1 -1
  144. package/dist/val/EmitFuncVal.js +6 -119
  145. package/dist/val/EmitFuncVal.js.map +1 -1
  146. package/dist/val/ExpectVal.js +0 -62
  147. package/dist/val/ExpectVal.js.map +1 -1
  148. package/dist/val/FilterFuncVal.js +0 -25
  149. package/dist/val/FilterFuncVal.js.map +1 -1
  150. package/dist/val/FuncBaseVal.d.ts +1 -0
  151. package/dist/val/FuncBaseVal.js +7 -127
  152. package/dist/val/FuncBaseVal.js.map +1 -1
  153. package/dist/val/GraphAtomVal.js +0 -15
  154. package/dist/val/GraphAtomVal.js.map +1 -1
  155. package/dist/val/HideFuncVal.js +0 -13
  156. package/dist/val/HideFuncVal.js.map +1 -1
  157. package/dist/val/IntegerVal.js +0 -61
  158. package/dist/val/IntegerVal.js.map +1 -1
  159. package/dist/val/JunctionVal.js +0 -20
  160. package/dist/val/JunctionVal.js.map +1 -1
  161. package/dist/val/KeyFuncVal.js +0 -46
  162. package/dist/val/KeyFuncVal.js.map +1 -1
  163. package/dist/val/ListVal.js +0 -57
  164. package/dist/val/ListVal.js.map +1 -1
  165. package/dist/val/LowerFuncVal.js +11 -6
  166. package/dist/val/LowerFuncVal.js.map +1 -1
  167. package/dist/val/MapVal.js +0 -151
  168. package/dist/val/MapVal.js.map +1 -1
  169. package/dist/val/MatchFuncVal.js +0 -27
  170. package/dist/val/MatchFuncVal.js.map +1 -1
  171. package/dist/val/{FormFuncVal.d.ts → MaybeFuncVal.d.ts} +5 -5
  172. package/dist/val/MaybeFuncVal.js +50 -0
  173. package/dist/val/MaybeFuncVal.js.map +1 -0
  174. package/dist/val/MoveFuncVal.js +0 -18
  175. package/dist/val/MoveFuncVal.js.map +1 -1
  176. package/dist/val/NilVal.js +2 -36
  177. package/dist/val/NilVal.js.map +1 -1
  178. package/dist/val/NomFuncVal.d.ts +12 -0
  179. package/dist/val/NomFuncVal.js +153 -0
  180. package/dist/val/NomFuncVal.js.map +1 -0
  181. package/dist/val/NumberVal.js +0 -15
  182. package/dist/val/NumberVal.js.map +1 -1
  183. package/dist/val/OpBaseVal.d.ts +1 -0
  184. package/dist/val/OpBaseVal.js +3 -15
  185. package/dist/val/OpBaseVal.js.map +1 -1
  186. package/dist/val/PackFuncVal.js +0 -34
  187. package/dist/val/PackFuncVal.js.map +1 -1
  188. package/dist/val/PathFuncVal.js +0 -6
  189. package/dist/val/PathFuncVal.js.map +1 -1
  190. package/dist/val/PathVal.js +0 -41
  191. package/dist/val/PathVal.js.map +1 -1
  192. package/dist/val/PlaceVal.js +0 -25
  193. package/dist/val/PlaceVal.js.map +1 -1
  194. package/dist/val/PlusOpVal.d.ts +1 -7
  195. package/dist/val/PlusOpVal.js +13 -74
  196. package/dist/val/PlusOpVal.js.map +1 -1
  197. package/dist/val/PrefFuncVal.js +0 -1
  198. package/dist/val/PrefFuncVal.js.map +1 -1
  199. package/dist/val/PrefVal.js +0 -167
  200. package/dist/val/PrefVal.js.map +1 -1
  201. package/dist/val/RecurseVal.js +0 -55
  202. package/dist/val/RecurseVal.js.map +1 -1
  203. package/dist/val/RefVal.js +0 -282
  204. package/dist/val/RefVal.js.map +1 -1
  205. package/dist/val/ReferFuncVal.js +0 -232
  206. package/dist/val/ReferFuncVal.js.map +1 -1
  207. package/dist/val/ScalarKindVal.js +0 -49
  208. package/dist/val/ScalarKindVal.js.map +1 -1
  209. package/dist/val/ScalarVal.js +0 -11
  210. package/dist/val/ScalarVal.js.map +1 -1
  211. package/dist/val/StrFuncVal.js +0 -18
  212. package/dist/val/StrFuncVal.js.map +1 -1
  213. package/dist/val/SuperFuncVal.js +0 -32
  214. package/dist/val/SuperFuncVal.js.map +1 -1
  215. package/dist/val/TopVal.js +0 -1
  216. package/dist/val/TopVal.js.map +1 -1
  217. package/dist/val/TranslateFuncVal.d.ts +12 -0
  218. package/dist/val/TranslateFuncVal.js +99 -0
  219. package/dist/val/TranslateFuncVal.js.map +1 -0
  220. package/dist/val/UpperFuncVal.js +11 -6
  221. package/dist/val/UpperFuncVal.js.map +1 -1
  222. package/dist/val/Val.d.ts +1 -0
  223. package/dist/val/Val.js +2 -133
  224. package/dist/val/Val.js.map +1 -1
  225. package/dist/val/VarVal.js +0 -12
  226. package/dist/val/VarVal.js.map +1 -1
  227. package/dist/val/arith.js +0 -37
  228. package/dist/val/arith.js.map +1 -1
  229. package/dist/val/caserange.d.ts +3 -0
  230. package/dist/val/caserange.js +49 -0
  231. package/dist/val/caserange.js.map +1 -0
  232. package/dist/val/members.js +0 -6
  233. package/dist/val/members.js.map +1 -1
  234. package/dist/val/numcmp.js +0 -11
  235. package/dist/val/numcmp.js.map +1 -1
  236. package/dist/val/numkind.js +0 -145
  237. package/dist/val/numkind.js.map +1 -1
  238. package/dist/val/valutil.js +0 -16
  239. package/dist/val/valutil.js.map +1 -1
  240. package/dist/vet.d.ts +12 -0
  241. package/dist/vet.js +159 -412
  242. package/dist/vet.js.map +1 -1
  243. package/dist/view.js +0 -414
  244. package/dist/view.js.map +1 -1
  245. package/dist/walk.js +0 -41
  246. package/dist/walk.js.map +1 -1
  247. package/grammar/aontu.abnf +9 -7
  248. package/grammar/aontu.gbnf +5 -5
  249. package/grammar/aontu.lark +5 -5
  250. package/grammar/aontu.tmLanguage.json +1 -1
  251. package/package.json +4 -2
  252. package/skill/SKILL.md +8 -0
  253. package/skill/init/check.sh +28 -0
  254. package/skill/init/data.aon +12 -0
  255. package/skill/init/model.aon +19 -0
  256. package/skill/tasks.md +151 -0
  257. package/src/agentsmd.ts +8 -32
  258. package/src/alias.ts +0 -39
  259. package/src/allow.ts +221 -0
  260. package/src/aontu.ts +10 -108
  261. package/src/aontumodel.ts +32 -0
  262. package/src/cli.ts +1009 -540
  263. package/src/ctx.ts +0 -103
  264. package/src/diff.ts +0 -40
  265. package/src/err.ts +0 -40
  266. package/src/escape.ts +0 -46
  267. package/src/exactjson.ts +0 -131
  268. package/src/format.ts +63 -234
  269. package/src/grammar.ts +72 -0
  270. package/src/graph.ts +0 -61
  271. package/src/hcanon.ts +0 -82
  272. package/src/helpdoc.ts +77 -0
  273. package/src/hints.ts +72 -49
  274. package/src/jsonschema.ts +0 -123
  275. package/src/keyorder.ts +0 -42
  276. package/src/lang.ts +39 -895
  277. package/src/lower.ts +15 -65
  278. package/src/lsp-server.ts +0 -16
  279. package/src/lsp.ts +12 -180
  280. package/src/mcp-server.ts +0 -31
  281. package/src/mcp.ts +0 -130
  282. package/src/mod-tool.ts +0 -158
  283. package/src/mod.ts +0 -178
  284. package/src/patch.ts +0 -232
  285. package/src/provenance.ts +0 -183
  286. package/src/query.ts +0 -84
  287. package/src/reach.ts +0 -53
  288. package/src/relation.ts +7 -71
  289. package/src/render.ts +33 -180
  290. package/src/report-sarif.ts +0 -48
  291. package/src/sig.ts +0 -35
  292. package/src/sigdecl.ts +1 -1
  293. package/src/siggate.ts +0 -30
  294. package/src/site.ts +3 -29
  295. package/src/subsume.ts +1 -161
  296. package/src/template.ts +69 -140
  297. package/src/trim.ts +0 -53
  298. package/src/type.ts +2 -45
  299. package/src/unify.ts +13 -251
  300. package/src/utility.ts +0 -31
  301. package/src/val/AbnfFuncVal.ts +181 -0
  302. package/src/val/AbsentVal.ts +54 -0
  303. package/src/val/AggFuncVal.ts +152 -188
  304. package/src/val/ArithFuncVal.ts +0 -20
  305. package/src/val/BagVal.ts +1 -78
  306. package/src/val/BigDecimalVal.ts +0 -16
  307. package/src/val/BigIntegerVal.ts +0 -16
  308. package/src/val/CloseFuncVal.ts +0 -9
  309. package/src/val/CmpFuncVal.ts +249 -0
  310. package/src/val/ConjunctVal.ts +0 -33
  311. package/src/val/ConstraintVal.ts +2 -537
  312. package/src/val/ContainerKindVal.ts +0 -18
  313. package/src/val/CopyFuncVal.ts +0 -5
  314. package/src/val/Decimal.ts +1 -185
  315. package/src/val/DeprecateFuncVal.ts +0 -10
  316. package/src/val/DisjunctVal.ts +0 -157
  317. package/src/val/EachFuncVal.ts +12 -53
  318. package/src/val/EmitFuncVal.ts +8 -208
  319. package/src/val/ExpectVal.ts +0 -62
  320. package/src/val/FilterFuncVal.ts +0 -55
  321. package/src/val/FuncBaseVal.ts +9 -130
  322. package/src/val/GraphAtomVal.ts +0 -42
  323. package/src/val/HideFuncVal.ts +0 -15
  324. package/src/val/IntegerVal.ts +0 -61
  325. package/src/val/JunctionVal.ts +0 -20
  326. package/src/val/KeyFuncVal.ts +0 -48
  327. package/src/val/ListVal.ts +0 -59
  328. package/src/val/LowerFuncVal.ts +12 -7
  329. package/src/val/MapVal.ts +0 -151
  330. package/src/val/MatchFuncVal.ts +0 -59
  331. package/src/val/MaybeFuncVal.ts +86 -0
  332. package/src/val/MoveFuncVal.ts +0 -20
  333. package/src/val/NilVal.ts +2 -36
  334. package/src/val/NomFuncVal.ts +200 -0
  335. package/src/val/NumberVal.ts +0 -16
  336. package/src/val/OpBaseVal.ts +4 -17
  337. package/src/val/PackFuncVal.ts +0 -63
  338. package/src/val/PathFuncVal.ts +0 -32
  339. package/src/val/PathVal.ts +0 -66
  340. package/src/val/PlaceVal.ts +0 -45
  341. package/src/val/PlusOpVal.ts +18 -75
  342. package/src/val/PrefFuncVal.ts +0 -1
  343. package/src/val/PrefVal.ts +0 -179
  344. package/src/val/RecurseVal.ts +0 -81
  345. package/src/val/RefVal.ts +1 -285
  346. package/src/val/ReferFuncVal.ts +0 -255
  347. package/src/val/ScalarKindVal.ts +0 -50
  348. package/src/val/ScalarVal.ts +0 -12
  349. package/src/val/StrFuncVal.ts +0 -44
  350. package/src/val/SuperFuncVal.ts +0 -42
  351. package/src/val/TopVal.ts +0 -1
  352. package/src/val/TranslateFuncVal.ts +132 -0
  353. package/src/val/UpperFuncVal.ts +12 -7
  354. package/src/val/Val.ts +3 -192
  355. package/src/val/VarVal.ts +0 -15
  356. package/src/val/arith.ts +0 -92
  357. package/src/val/caserange.ts +53 -0
  358. package/src/val/members.ts +0 -23
  359. package/src/val/numcmp.ts +1 -27
  360. package/src/val/numkind.ts +0 -149
  361. package/src/val/valutil.ts +0 -16
  362. package/src/vet.ts +209 -504
  363. package/src/view.ts +0 -507
  364. package/src/walk.ts +0 -41
  365. package/dist/std.d.ts +0 -3
  366. package/dist/std.js +0 -637
  367. package/dist/std.js.map +0 -1
  368. package/dist/val/FormFuncVal.js +0 -55
  369. package/dist/val/FormFuncVal.js.map +0 -1
  370. package/src/std.ts +0 -648
  371. package/src/val/FormFuncVal.ts +0 -119
package/src/cli.ts CHANGED
@@ -1,12 +1,5 @@
1
1
  /* Copyright (c) 2025 Richard Rodger, MIT License */
2
2
 
3
- // Command-line interface for Aontu.
4
- //
5
- // aontu [options] [file]
6
- //
7
- // With a file argument, the file is evaluated and the result printed.
8
- // With no file on an interactive terminal, a REPL is started. With no
9
- // file and piped input, the source is read from stdin. See HELP below.
10
3
 
11
4
  // Named imports, not `import * as`: the namespace form makes tsc emit the
12
5
  import { evalFailure } from './query'
@@ -23,11 +16,16 @@ import {
23
16
  exactJSON, vet, subsume, trimCheck, relationCheck,
24
17
  hcanon, canonHash,
25
18
  get, why, patch, agentsMd,
19
+ allow,
26
20
  render,
27
21
  renderProfile,
28
22
  } from './aontu'
23
+ import type { AllowDecision, AllowReport, AllowVerdict } from './allow'
29
24
  import type { RenderCoverage, RenderReport } from './render'
30
- import { desugarTemplate, resugarTemplate, templateOutputs, markerFor } from './template'
25
+ import {
26
+ desugarTemplate, resugarTemplate, templateOutputs, markerFor,
27
+ markerFromProfiles,
28
+ } from './template'
31
29
  import { outsideRoot } from './mcp'
32
30
  import { sarifReport } from './report-sarif'
33
31
  import { main as lspMain } from './lsp-server'
@@ -39,7 +37,9 @@ import type {
39
37
  } from './mod-tool'
40
38
  import { modCacheDir } from './mod'
41
39
  import { VET_MAX_ERRORS } from './vet'
42
- import type { VetReport, VetFinding, VetVerdict } from './vet'
40
+ import type {
41
+ VetReport, VetFinding, VetVerdict, VetCoverage,
42
+ } from './vet'
43
43
  import type {
44
44
  SubsumeReport, SubsumeVerdict, SubsumeProfile,
45
45
  } from './subsume'
@@ -57,11 +57,21 @@ import type { WhyRecord } from './provenance'
57
57
  import { agentsMdSplice } from './agentsmd'
58
58
  import { format, unifiedDiff } from './format'
59
59
  import { includeOpts } from './utility'
60
+ import { HELPDOC, INITDOC } from './helpdoc'
61
+ import type { HelpTopic } from './helpdoc'
62
+ import { hints, codeClasses, codeClass } from './hints'
63
+ import { cmpCodePoint } from './keyorder'
60
64
  import type { IncludeOptions } from './utility'
61
65
 
62
66
 
63
67
  type Mode = 'json' | 'canon'
64
68
 
69
+ // The REPORT form of the bare command (G11 phase 7), which is a
70
+ // separate axis from Mode: `--canon` chooses what the answer IS,
71
+ // `--format` chooses how the answer is WRAPPED. Every other verb
72
+ // spells the second one this way.
73
+ type EvalFormat = 'text' | 'json'
74
+
65
75
 
66
76
  const HELP = `Usage: aontu [options] [file]
67
77
  aontu vet [options] <schema> <data> [more-data...]
@@ -76,14 +86,20 @@ const HELP = `Usage: aontu [options] [file]
76
86
  aontu render [--at <path>] [--profile <file>]... [--unit <path>]
77
87
  [--stdout | --out <dir> | --check <dir> | --coverage]
78
88
  [--coverage-at <path>] [--strict] <file>
79
- aontu template [--resugar] [--check] [--marker <token>] <file>
89
+ aontu template [--resugar] [--check] [--marker <token>]
90
+ [--profile <file>] <file>
80
91
  aontu hash [options] <file>
81
92
  aontu mod tidy|verify|vendor|manifest [options] [dir]
82
93
  aontu get <path> [options] <file>
83
94
  aontu why <path> [options] <file>
84
95
  aontu set <path>=<value>... --entry <file> --overlay <file>
85
- aontu agentsmd [--write <AGENTS.md>] <file>
86
- aontu fmt [-w|-l|--check|-d|--lint] [--marker <token>] <file>...
96
+ aontu allow --role <role> [--at <path>] <roles-file> <path>...
97
+ aontu agentsmd [--write <AGENTS.md>] [--depth <n>] <file>
98
+ aontu fmt [-w|-l|--check|-d|--lint] [--marker <token>]
99
+ [--profile <file>] <file>...
100
+ aontu help [topic] [--format text|json]
101
+ aontu explain <code> | --list [--format text|json]
102
+ aontu init [dir]
87
103
  aontu lsp
88
104
  aontu mcp [--root <dir>]
89
105
 
@@ -91,6 +107,34 @@ Evaluate an aontu source file and print the result as JSON.
91
107
  With no file on an interactive terminal, start a REPL.
92
108
  With no file and piped input, read the source from stdin.
93
109
 
110
+ NEW TO THE LANGUAGE? This page documents the TOOL. The documentation
111
+ of the LANGUAGE travels inside this binary, and this is how to reach it:
112
+
113
+ aontu help List the topics this binary carries
114
+ aontu help tasks Which verb does the job you have
115
+ aontu help language The whole grammar, on one page
116
+ aontu help examples The ladder, from plain JSON upward
117
+ aontu help codes What a refusal means
118
+ aontu help grammar The published ABNF
119
+ aontu explain <code> What one error code a report carries means
120
+ aontu explain --list Every registered code with its class
121
+
122
+ Every one of those answers with no network and no checkout. The
123
+ long-form documentation -- the tutorial, the language and API
124
+ references, the how-to guides -- is in docs/ of the repository, which
125
+ is where to go when the topics above are not enough; the contributor
126
+ and agent guide is AGENTS.md beside it.
127
+
128
+ NOTHING TO EDIT YET? aontu init [dir] writes a working model, an
129
+ instance of it, and the four checks to run -- so the first
130
+ document is an edit of something that already holds, rather than an
131
+ invention. It refuses to overwrite.
132
+
133
+ The one construct to know before writing anything: &: inside a map is
134
+ a TEMPLATE that every key of that map must satisfy. A quoted "*" is a
135
+ key named *, not a wildcard, and a schema written that way constrains
136
+ nothing while still reporting valid.
137
+
94
138
  The vet verb validates data documents against a schema document and
95
139
  reports what does not hold, as text or as a machine-readable object.
96
140
 
@@ -100,7 +144,11 @@ query between a document and its own earlier versions.
100
144
 
101
145
  Options:
102
146
  -c, --canon Print the canonical form instead of generated JSON
103
- -h, --help Show this help and exit
147
+ --format <f> text (default) or json. The json form wraps the
148
+ answer as {aontu, findings, ok, out}, so a failure
149
+ here reads like every other verb's
150
+ -h, --help Show this help and exit (the verbs and their flags);
151
+ aontu help is the LANGUAGE, and lists its own topics
104
152
  --jsonl REPL: answer every command as one JSON line
105
153
  -v, --version Print the version and exit
106
154
  --trust <t> Include capability: system (default), none, or
@@ -131,12 +179,28 @@ Vet options:
131
179
  --closed Refuse keys the anchor does not declare
132
180
  --partial Residue is reported but does not fail the run
133
181
  --max-errors <n> Cap the finding list (default 20)
182
+ --coverage Report what the check EXAMINED: how many data
183
+ leaves a schema declaration constrained, the
184
+ shallowest data paths none did, and the
185
+ declarations no data met
186
+ --strict-coverage --coverage, and exit 1 when the run was VACUOUS --
187
+ when no data leaf was constrained at all. The
188
+ verdict word is unchanged, so nothing that passes
189
+ today starts failing without this flag
190
+ --coverage-at <p> Measure coverage under this path of the data only
134
191
  --format <f> text (default), json or sarif
135
192
  --watch Re-run whenever a watched file changes
136
193
 
194
+ A check that examined NOTHING and a check that passed answer the same
195
+ without --coverage. The usual cause is a schema written with the
196
+ wildcard other tools use: a quoted "*" is a key NAMED *, not a
197
+ template, so it constrains nothing and the run still reports valid.
198
+ The template is &: -- see aontu help language.
199
+
137
200
  Vet exit codes:
138
201
  0 valid data unifies, and is concrete (or --partial)
139
- 1 invalid at least one contradiction
202
+ 1 invalid at least one contradiction, or a vacuous run under
203
+ --strict-coverage
140
204
  2 usage bad option, or a file that cannot be read
141
205
  3 incomplete no contradiction, but the truth is not yet satisfied
142
206
  4 error the schema is unusable on its own
@@ -272,8 +336,9 @@ up on its own, or a relation, root or path that names nothing.
272
336
  Render options:
273
337
  --at <path> Render the value at this path ($.a.b); the root by
274
338
  default
275
- --profile <file> A profile document, profile: {lang, ...}, vetted
276
- against aontu:profile; repeatable, one per language
339
+ --profile <file> A profile document, aontu: render: Lang: {lang,
340
+ ...}, vetted against aontu:render; repeatable,
341
+ one per language
277
342
  --unit <path> Render only the unit with this path
278
343
  --stdout One unit's bytes and nothing else (with --unit when
279
344
  the instance has several)
@@ -297,8 +362,8 @@ does not stand up or the instance is not aontu:code.
297
362
  A render entry file whose extension is not .aon is a TEMPLATE: a
298
363
  generator in the target's own syntax, whose marker lines carry aontu
299
364
  and whose other lines are output. It is desugared before it is
300
- evaluated, and --marker names the marker for a language the table does
301
- not know.
365
+ evaluated, and a language the table does not know names its marker with
366
+ --marker, or declares it once in a profile file that --profile reads.
302
367
 
303
368
  Template options:
304
369
  --resugar The file is the canonical aontu; print the template
@@ -306,7 +371,9 @@ Template options:
306
371
  --check Desugar and resugar, and exit 1 if the file is not
307
372
  what the round trip answers
308
373
  --marker <t> The marker, when the extension does not name it
309
- (default //-, and #- --- /*- by extension)
374
+ (default //-, and #- --- /*- <!--- by extension)
375
+ --profile <f> A profile file, whose template.ext names the
376
+ extensions it marks and template.marker the marker
310
377
 
311
378
  The template verb prints the canonical aontu form of a generator
312
379
  written in the target's own syntax: a marked line is aontu source, and
@@ -334,14 +401,61 @@ change contradicts a pinned value -- aontu why locates it, and
334
401
  --in-place rewrites it), 2 usage, 3 incomplete, 4 the entry does not
335
402
  stand up on its own.
336
403
 
404
+ Allow options:
405
+ --role <role> The role the caller is operating under (required)
406
+ --at <path> Where the roles map lives in the role model
407
+ (default $.roles)
408
+ --format <f> text (default) or json
409
+
410
+ The allow verb asks a role model whether a role may modify every one
411
+ of the given subtrees, and answers before the change is made. The
412
+ role model is an aontu document: one entry per role, each carrying
413
+ allow (the subtrees it may modify) and optionally deny (the ones it
414
+ may not), as path strings starting at $; * in a path matches any one
415
+ key. A path is allowed when an allow entry is at or above it, and
416
+ refused when a deny entry is at, above or below it, whatever the
417
+ order. Every path starts with $, and may be spelled as set's
418
+ assignment, <path>=<value>, whose value must be one value: a value
419
+ carrying a second pair would write a subtree the gate was not asked
420
+ about.
421
+
422
+ Allow exit codes: 0 allowed (every path), 1 refused (at least one
423
+ path, or a role the model does not declare), 2 usage, 4 the role
424
+ model does not stand up on its own.
425
+
337
426
  Agentsmd options:
338
427
  --write <file> Splice the stanza into this file between the
339
428
  aontu:begin and aontu:end markers, appending them
340
429
  when they are absent; the rest is left alone
430
+ --depth <n> How deep the shape line projects (default 2). Two
431
+ levels name the root keys and say top under them; a
432
+ caller that wants the fields asks for them
341
433
 
342
434
  Agentsmd exit codes: 0 generated, 2 usage, 4 the document does not
343
435
  stand up on its own.
344
436
 
437
+ Help options:
438
+ --format <f> text (default) or json, the topic and its text
439
+
440
+ The help verb prints the embedded teaching pack: the language, not the
441
+ tool. With no topic it lists them. Topics are tasks, language,
442
+ examples, codes and grammar; the corpus is generated from docs/skill/
443
+ and grammar/aontu.abnf, so it cannot drift from those sources.
444
+
445
+ Help exit codes: 0 printed, 2 an unknown topic (the topics are listed)
446
+ or a bad option.
447
+
448
+ Explain options:
449
+ --list Every registered error code with its class
450
+ --format <f> text (default) or json
451
+
452
+ The explain verb answers what one error code means, from the same
453
+ table the engine attaches to a finding. Every registered code has an
454
+ entry, so a code read out of a report always resolves.
455
+
456
+ Explain exit codes: 0 explained, 2 an unknown code (near matches are
457
+ named) or a bad option.
458
+
345
459
  Fmt options:
346
460
  -w, --write Rewrite each file in place, when its form would change
347
461
  -l, --list Print the name of each file whose form would change
@@ -352,7 +466,9 @@ Fmt options:
352
466
  shapes, on standard error, and print nothing else
353
467
  --strict With --lint, and exit 1 when there is a finding
354
468
  --marker <t> The file is a generator, and this is its marker
355
- (default //-, and #- --- /*- by extension)
469
+ (default //-, and #- --- /*- <!--- by extension)
470
+ --profile <f> A profile file, whose template.ext names the
471
+ extensions it marks and template.marker the marker
356
472
 
357
473
  The fmt verb prints one document in the agreed form; with no file it
358
474
  reads standard input. Several files need one of the options above.
@@ -399,51 +515,78 @@ function version(): string {
399
515
  }
400
516
 
401
517
 
518
+ const EVAL_ANSI = new RegExp('\u001b\\[[0-9;]*m', 'g')
519
+
520
+
521
+ function evalFinding(code: string, text: string): VetFinding {
522
+ return {
523
+ class: codeClass(code),
524
+ code,
525
+ message: text.split('\n')[0].replace(EVAL_ANSI, ''),
526
+ path: '$',
527
+ severity: 'error',
528
+ sites: [],
529
+ }
530
+ }
531
+
532
+
402
533
  // Evaluate source, returning either the rendered output or the error
403
- // message. Never throws.
534
+ // message, and the failure in the finding shape. Never throws.
404
535
  function evalSource(
405
536
  aontu: Aontu,
406
537
  src: string,
407
538
  mode: Mode,
408
- ): { ok: boolean; text: string } {
539
+ ): { ok: boolean; text: string; findings: VetFinding[] } {
409
540
  try {
410
- // exactJSON, not JSON.stringify: a document using the `0d` exact
411
- // leaves generates bigints and Decimals, which JSON.stringify cannot
412
- // write (D9). The CLI prints INDENTED JSON and the shared suite's
413
- // `gens` mode prints COMPACT JSON, but both go through this one
414
- // emitter -- an indent argument rather than a second implementation,
415
- // so the two cannot drift from each other or from the Go port.
416
541
  const text = 'canon' === mode
417
542
  ? aontu.unify(src).canon
418
543
  : exactJSON(aontu.generate(src), 2)
419
- return { ok: true, text }
544
+ return { ok: true, text, findings: [] }
420
545
  }
421
546
  catch (err: any) {
422
547
  const msg = (err instanceof AontuError || true === err?.aontu)
423
548
  ? err.message
424
549
  : String(err?.message ?? err)
425
- return { ok: false, text: msg }
550
+ const errs: any[] = 'function' === typeof err?.errs ? err.errs() : []
551
+ const first: any = errs[0]
552
+ return {
553
+ ok: false,
554
+ text: msg,
555
+ findings: null == first ? [] : [evalFinding(first.why, msg)],
556
+ }
557
+ }
558
+ }
559
+
560
+
561
+ // The bare command's answer, in the form the caller asked for. The
562
+ // text form is what it has always printed, on the stream the verdict
563
+ // chooses; `--format json` is the same answer as one object, on
564
+ // stdout, so a harness reads one stream and one shape either way.
565
+ // Mirrors emit in go/cmd/aontu/main.go.
566
+ function emitEval(
567
+ res: { ok: boolean; text: string; findings: VetFinding[] },
568
+ format: EvalFormat,
569
+ ): number {
570
+ if ('json' === format) {
571
+ process.stdout.write(exactJSON({
572
+ aontu: { version: version(), verb: 'eval' },
573
+ findings: res.findings,
574
+ ok: res.ok,
575
+ out: res.ok ? res.text : '',
576
+ }, 2) + '\n')
577
+ }
578
+ else {
579
+ ;(res.ok ? process.stdout : process.stderr).write(res.text + '\n')
426
580
  }
581
+ return res.ok ? 0 : 1
427
582
  }
428
583
 
429
584
 
430
- // The include capability the main verb runs with (G5, docs/trust.md).
431
- // `--trust` and `--include-root` set it explicitly; the default is
432
- // 'system' WITH the warning window: every resolution that escapes the
433
- // entry root or goes through package resolution prints a one-line
434
- // stderr warning naming the flag a future default will require
435
- // (phase 6, the staged flip).
436
585
  type TrustArg = (
437
586
  | { kind: 'system-warn' }
438
587
  | { kind: 'system' }
439
588
  | { kind: 'none' }
440
589
  | { kind: 'root', dir?: string }
441
- // EXTENSIONS READ AS TEXT ride with the capability rather than
442
- // beside it: both answer "what may an include read", both are
443
- // stripped by takeTrust before a verb parses its own tail, and a
444
- // verb that threads one and not the other is the G5 defect again --
445
- // `aontu vet` running under a flag the bare command honoured and it
446
- // did not.
447
590
  ) & { textExt: string[] }
448
591
 
449
592
 
@@ -487,16 +630,6 @@ function trustOpts(trust: TrustArg, entryRoot: string): any {
487
630
  }
488
631
 
489
632
 
490
- // EVERY VERB honours the include capability, not just the bare
491
- // command. G5 wired `--trust`/`--include-root` to `aontu <file>` alone,
492
- // so `aontu vet schema.aon data.json` -- the surface an agent actually
493
- // scripts -- ran the full system resolver with no flag to confine it
494
- // and no warning (use-cases/REVIEW.md finding G). The flags are
495
- // stripped here, before each verb parses its own tail, so a verb only
496
- // has to pass the profile on to its engine.
497
- //
498
- // Returns undefined when the spelling is wrong, with the message
499
- // already printed: the caller answers the usage class.
500
633
  function takeTrust(argv: string[]):
501
634
  { argv: string[], trust: TrustArg } | undefined {
502
635
  const rest: string[] = []
@@ -600,35 +733,40 @@ function entryRootOf(file: string | undefined): string {
600
733
  }
601
734
 
602
735
 
603
- function runFile(file: string, mode: Mode, trust: TrustArg): number {
736
+ function runFile(
737
+ file: string, mode: Mode, format: EvalFormat, trust: TrustArg): number {
604
738
  let src: string
605
739
  try {
606
740
  src = readFileSync(file, 'utf8')
607
741
  }
608
742
  catch (err: any) {
743
+ if (looksLikeVerb(file)) {
744
+ process.stderr.write(
745
+ `aontu: \`${file}\` is not a file, and not a verb this port knows\n`)
746
+ const near = nearestVerb(file, KNOWN_VERBS)
747
+ if ('' !== near) {
748
+ process.stderr.write(`aontu: did you mean \`aontu ${near}\`?\n`)
749
+ }
750
+ process.stderr.write(
751
+ 'aontu: `aontu --help` lists the verbs, `aontu help` the topics\n')
752
+ return 2
753
+ }
609
754
  process.stderr.write(`aontu: cannot read ${file}: ${err.message}\n`)
610
755
  return 1
611
756
  }
612
757
 
613
758
  const path = resolve(file)
614
- // `fs` IS WHAT MAKES A FRAME EXCERPT THE FILE IT NAMES. Without it,
615
- // err.ts's resolveSrc falls back to the ENTRY text, so a frame whose
616
- // arrow says `lib/types.aon:2:6` printed the entry's line 2 under it
617
- // -- a real file name over another file's line, which
618
- // docs/reference-api.md forbids in the same words it uses to require
619
- // the name.
620
759
  const aontu = new Aontu({
621
760
  path,
622
761
  errfs: { existsSync, readFileSync },
623
762
  ...trustOpts(trust, dirname(path)),
624
763
  })
625
- const res = evalSource(aontu, src, mode)
626
- ;(res.ok ? process.stdout : process.stderr).write(res.text + '\n')
627
- return res.ok ? 0 : 1
764
+ return emitEval(evalSource(aontu, src, mode), format)
628
765
  }
629
766
 
630
767
 
631
- function runStdin(mode: Mode, trust: TrustArg): Promise<number> {
768
+ function runStdin(
769
+ mode: Mode, format: EvalFormat, trust: TrustArg): Promise<number> {
632
770
  return new Promise((resolve) => {
633
771
  let src = ''
634
772
  process.stdin.setEncoding('utf8')
@@ -636,22 +774,12 @@ function runStdin(mode: Mode, trust: TrustArg): Promise<number> {
636
774
  process.stdin.on('end', () => {
637
775
  const res = evalSource(
638
776
  new Aontu(trustOpts(trust, process.cwd())), src, mode)
639
- ;(res.ok ? process.stdout : process.stderr).write(res.text + '\n')
640
- resolve(res.ok ? 0 : 1)
777
+ resolve(emitEval(res, format))
641
778
  })
642
779
  })
643
780
  }
644
781
 
645
782
 
646
- // THE REPL AS AN INSPECTION TOOL (G7 phase 7): `:load` holds a
647
- // document, and `:get`, `:keys` and `:why` ask the query and
648
- // provenance surfaces about it, so the session is a place to
649
- // INTERROGATE a definition rather than only to evaluate snippets.
650
- //
651
- // The command handler is a PURE FUNCTION of (state, line): a readline
652
- // loop is untestable, and every answer this REPL gives has to be as
653
- // checkable as the CLI's. File reading is injected for the same
654
- // reason.
655
783
  export type ReplState = {
656
784
  // How a value renders: the `:canon` / `:json` toggle.
657
785
  mode: Mode
@@ -660,11 +788,6 @@ export type ReplState = {
660
788
  jsonl: boolean
661
789
  name?: string
662
790
  src?: string
663
- // The include capability the session evaluates under. `--trust` and
664
- // `--include-root` were parsed and then DROPPED on the way to the
665
- // REPL, so `--jsonl` -- the surface built to be driven by a harness
666
- // -- ran unconfined however it was invoked (use-cases/REVIEW.md
667
- // finding G). The state carries it, so every line honours it.
668
791
  trust?: TrustArg
669
792
  }
670
793
 
@@ -819,14 +942,6 @@ function runRepl(initialMode: Mode, jsonl: boolean, trust: TrustArg): void {
819
942
  })
820
943
 
821
944
  rl.on('close', () => {
822
- // The closing newline is for a HUMAN, so it is written only for
823
- // one: it moves the terminal off the prompt line that `rl` left
824
- // hanging. In `--jsonl` there is no prompt, every answer already
825
- // ends in its own newline, and this one appended a bare empty line
826
- // to the stream -- a record that is not JSON, at the end of a
827
- // protocol whose whole contract is one JSON object per line. A
828
- // harness parsing every line it receives failed on it, after the
829
- // commands had all succeeded. Mirrors go/cmd/aontu/repl.go.
830
945
  if (!jsonl) {
831
946
  process.stdout.write('\n')
832
947
  }
@@ -838,16 +953,6 @@ function runRepl(initialMode: Mode, jsonl: boolean, trust: TrustArg): void {
838
953
  }
839
954
 
840
955
 
841
-
842
- // THE VET VERB (G2 phase 3).
843
- //
844
- // Exit codes are VERDICT CLASSES, not a pass/fail bit: an agent loop
845
- // branches on "the data contradicts the truth" (1) differently from
846
- // "the data has not supplied everything the truth requires" (3), and
847
- // differently again from "the schema itself is broken" (4), which is
848
- // never the data's fault. 2 stays what it already was for this CLI --
849
- // the caller got the invocation wrong -- which is why an unreadable
850
- // file is a 2 rather than a 4.
851
956
  const VET_EXIT: Record<VetVerdict, number> = {
852
957
  valid: 0,
853
958
  invalid: 1,
@@ -870,6 +975,11 @@ type VetArgs = {
870
975
  partial?: boolean
871
976
  maxErrors?: number
872
977
  watch?: boolean
978
+ // G11 phase 5. `strictCoverage` implies `coverage`; `coverageAt`
979
+ // narrows the data side and implies it too.
980
+ coverage?: boolean
981
+ strictCoverage?: boolean
982
+ coverageAt?: string
873
983
  }
874
984
 
875
985
 
@@ -883,6 +993,9 @@ function parseVetArgs(argv: string[]): { args?: VetArgs; err?: string } {
883
993
  let partial = false
884
994
  let maxErrors: number | undefined
885
995
  let watch = false
996
+ let coverage = false
997
+ let strictCoverage = false
998
+ let coverageAt: string | undefined
886
999
 
887
1000
  for (let i = 0; i < argv.length; i++) {
888
1001
  const arg = argv[i]
@@ -909,14 +1022,6 @@ function parseVetArgs(argv: string[]): { args?: VetArgs; err?: string } {
909
1022
  format = f
910
1023
  }
911
1024
  else if ('--max-errors' === arg) {
912
- // ONE GRAMMAR, spelled the same way in both ports: decimal
913
- // digits, one to nine of them, at least 1. `Number()` alone
914
- // accepted `1.0`, `1e2`, `0x10` and ` 3`, which Go's parser
915
- // refuses -- so the same documented invocation meant different
916
- // things in the two shipped commands. The nine-digit ceiling is
917
- // where the ports would part company again: beyond it Go's
918
- // integer conversion saturates, and a cap nobody can reach is
919
- // not worth a divergence.
920
1025
  const raw = argv[++i]
921
1026
  if (!/^[0-9]{1,9}$/.test(raw ?? '') || 1 > Number(raw)) {
922
1027
  return { err: 'aontu: --max-errors needs a positive whole number' }
@@ -929,6 +1034,24 @@ function parseVetArgs(argv: string[]): { args?: VetArgs; err?: string } {
929
1034
  else if ('--partial' === arg) {
930
1035
  partial = true
931
1036
  }
1037
+ else if ('--coverage' === arg) {
1038
+ coverage = true
1039
+ }
1040
+ else if ('--strict-coverage' === arg) {
1041
+ // IMPLIES THE ACCOUNTING, because a gate cannot fire on what was
1042
+ // never measured. Asking for the strict form and having to
1043
+ // remember `--coverage` beside it is a usage trap with one
1044
+ // correct answer, so the flag takes it.
1045
+ coverage = true
1046
+ strictCoverage = true
1047
+ }
1048
+ else if ('--coverage-at' === arg) {
1049
+ coverageAt = argv[++i]
1050
+ if (null == coverageAt) {
1051
+ return { err: 'aontu: --coverage-at needs a path' }
1052
+ }
1053
+ coverage = true
1054
+ }
932
1055
  else if ('--watch' === arg) {
933
1056
  watch = true
934
1057
  }
@@ -954,6 +1077,9 @@ function parseVetArgs(argv: string[]): { args?: VetArgs; err?: string } {
954
1077
  partial,
955
1078
  maxErrors,
956
1079
  watch,
1080
+ coverage,
1081
+ strictCoverage,
1082
+ coverageAt,
957
1083
  },
958
1084
  }
959
1085
  }
@@ -978,10 +1104,6 @@ function renderFinding(f: VetFinding): string {
978
1104
  out.push(` actual: ${f.actual}`)
979
1105
  }
980
1106
  for (const s of f.sites) {
981
- // Every site carries the canon of the value it stands for: that is
982
- // what makes the two sides of a conflict readable side by side. A
983
- // site's file is always a string -- empty when the value belongs to
984
- // neither document -- so there is nothing to coalesce here.
985
1107
  out.push(` ${s.role}: ${s.file}:${s.row}:${s.col} (${s.value})`)
986
1108
  }
987
1109
 
@@ -993,14 +1115,53 @@ function renderVetText(report: VetReport): string {
993
1115
  const head = `verdict: ${report.verdict}` +
994
1116
  (report.truncated ? ' (findings truncated)' : '')
995
1117
 
996
- if (0 === report.findings.length) {
997
- return head
998
- }
1118
+ const body = 0 === report.findings.length ? []
1119
+ : ['', ...report.findings.map(renderFinding)]
1120
+ const cover = null == report.coverage ? []
1121
+ : ['', ...renderVetCoverage(report.coverage)]
999
1122
 
1000
- return [head, ''].concat(report.findings.map(renderFinding)).join('\n')
1123
+ return [head, ...body, ...cover].join('\n')
1001
1124
  }
1002
1125
 
1003
1126
 
1127
+ // The coverage block (G11 phase 5). VACUOUS FIRST and in the
1128
+ // imperative, because it is the one line that changes what the reader
1129
+ // should do: a `valid` verdict above it means nothing.
1130
+ function renderVetCoverage(c: VetCoverage): string[] {
1131
+ const out: string[] = []
1132
+ if (c.vacuous) {
1133
+ out.push('coverage: VACUOUS — no data leaf was constrained' +
1134
+ ' by the schema; this run checked nothing')
1135
+ }
1136
+ out.push(`coverage: ${c.checked}/${c.leaves} data leaves checked,` +
1137
+ ` ${c.declared} schema declarations`)
1138
+ // The lists are the SHALLOWEST paths, so each names a subtree rather
1139
+ // than every leaf under it, and both are capped: a report a reader
1140
+ // scrolls past is a report nobody reads.
1141
+ for (const [label, paths] of [
1142
+ ['unchecked', c.unchecked], ['unused', c.unused],
1143
+ ] as [string, string[]][]) {
1144
+ if (0 === paths.length) {
1145
+ continue
1146
+ }
1147
+ const shown = paths.slice(0, COVERAGE_LIST_MAX)
1148
+ for (const p of shown) {
1149
+ out.push(` ${label}: ${p}`)
1150
+ }
1151
+ if (shown.length < paths.length) {
1152
+ out.push(` ${label}: … and ${paths.length - shown.length} more`)
1153
+ }
1154
+ }
1155
+ return out
1156
+ }
1157
+
1158
+
1159
+ // How many coverage paths the TEXT form prints per list. The JSON form
1160
+ // carries every one: a machine reads the whole list, a person reads the
1161
+ // first few and the count.
1162
+ const COVERAGE_LIST_MAX = 10
1163
+
1164
+
1004
1165
  // The machine-readable form. `aontu` names the producer, so a report
1005
1166
  // read from a file or a pipe says which version and which verb made it
1006
1167
  // without the consumer having to know.
@@ -1010,6 +1171,7 @@ function renderVetJson(report: VetReport): string {
1010
1171
  verdict: report.verdict,
1011
1172
  truncated: report.truncated,
1012
1173
  findings: report.findings,
1174
+ ...(null == report.coverage ? {} : { coverage: report.coverage }),
1013
1175
  }, 2)
1014
1176
  }
1015
1177
 
@@ -1051,12 +1213,17 @@ function vetOnce(args: VetArgs, trust: TrustArg): number {
1051
1213
  return 2
1052
1214
  }
1053
1215
 
1054
- // Each data file is vetted on its own, because a parsed tree is
1055
- // single-use (docs/reference-api.md) -- and because two data files
1056
- // are two candidates for the same truth, not one merged candidate.
1057
1216
  let verdict: VetVerdict = 'valid'
1058
1217
  let truncated = false
1059
1218
  const findings: VetFinding[] = []
1219
+ let cov: VetCoverage | undefined
1220
+ // Initialised rather than left undefined: it is filled in the same
1221
+ // block that sets `cov`, so a fallback at the read below would be an
1222
+ // arm nothing can take. The FIRST file replaces it wholesale, which
1223
+ // is what makes the fold an intersection rather than an empty set.
1224
+ let unusedEvery = new Set<string>()
1225
+ let unusedSeen = false
1226
+ const uncheckedAll = new Set<string>()
1060
1227
 
1061
1228
  for (const source of sources) {
1062
1229
  const report = vet(schemaSrc, source.src, {
@@ -1067,14 +1234,10 @@ function vetOnce(args: VetArgs, trust: TrustArg): number {
1067
1234
  maxErrors: args.maxErrors,
1068
1235
  schemaUrl: args.schema,
1069
1236
  dataUrl: source.file,
1070
- // The paths as well as the labels: a relative `@"file"` load
1071
- // inside either document resolves from ITS OWN directory, the
1072
- // way `aontu <file>` already resolves one (runFile above). The
1073
- // path is passed AS TYPED, not resolved: it doubles as the
1074
- // label above, and a report that mixed the typed path with an
1075
- // absolute one would name the same file two ways.
1076
1237
  schemaPath: args.schema,
1077
1238
  dataPath: source.file,
1239
+ coverage: args.coverage,
1240
+ coverageAt: args.coverageAt,
1078
1241
  })
1079
1242
 
1080
1243
  if (VET_RANK[verdict] < VET_RANK[report.verdict]) {
@@ -1083,40 +1246,59 @@ function vetOnce(args: VetArgs, trust: TrustArg): number {
1083
1246
  truncated = truncated || report.truncated
1084
1247
  findings.push(...report.findings)
1085
1248
 
1086
- // A SCHEMA-SIDE FAULT IS THE SAME FAULT FOR EVERY DATA FILE, so it
1087
- // is reported ONCE. `error` means exactly that -- the run could not
1088
- // be set up from the truth's side, never the data's (the exit table
1089
- // in docs/reference-api.md) -- so the report the first file
1090
- // produced is the report every later file would produce, character
1091
- // for character. Concatenating them repeated one broken schema N
1092
- // times and, past the cap, marked the report `truncated` over a
1093
- // single underlying fault. It only became visible once the `error`
1094
- // verdict started carrying findings at all: while the list was
1095
- // empty there was nothing to duplicate.
1249
+ if (null != report.coverage) {
1250
+ const c = report.coverage
1251
+ cov = null == cov ? { ...c } : {
1252
+ checked: cov.checked + c.checked,
1253
+ declared: c.declared,
1254
+ leaves: cov.leaves + c.leaves,
1255
+ unchecked: [],
1256
+ unused: [],
1257
+ vacuous: false,
1258
+ }
1259
+ for (const p of c.unchecked) {
1260
+ uncheckedAll.add(p)
1261
+ }
1262
+ const mine = new Set(c.unused)
1263
+ unusedEvery = unusedSeen
1264
+ ? new Set([...unusedEvery].filter((u) => mine.has(u))) : mine
1265
+ unusedSeen = true
1266
+ }
1267
+
1096
1268
  if ('error' === report.verdict) {
1097
1269
  break
1098
1270
  }
1099
1271
  }
1100
1272
 
1101
- // The cap is on the REPORT, not on each file. Capping every file's
1102
- // list and then concatenating them let `--max-errors 1` emit one
1103
- // finding PER FILE -- and leave `truncated` false while doing it,
1104
- // because no single file had been cut. The engine still caps each
1105
- // run, so a pathological file cannot flood the aggregate before it
1106
- // gets here; this is the second, honest cut.
1107
1273
  const cap = args.maxErrors ?? VET_MAX_ERRORS
1108
1274
  const kept = cap < findings.length ? findings.slice(0, cap) : findings
1109
1275
 
1276
+ if (null != cov) {
1277
+ cov.unchecked = [...uncheckedAll].sort(cmpCodePoint)
1278
+ cov.unused = [...unusedEvery].sort(cmpCodePoint)
1279
+ cov.vacuous = 0 === cov.checked && 0 < cov.leaves
1280
+ }
1281
+
1110
1282
  const report: VetReport = {
1111
1283
  verdict,
1112
1284
  truncated: truncated || cap < findings.length,
1113
1285
  findings: kept,
1286
+ ...(null == cov ? {} : { coverage: cov }),
1114
1287
  }
1115
1288
  const text = 'json' === args.format ? renderVetJson(report) :
1116
1289
  'sarif' === args.format ? renderVetSarif(report) :
1117
1290
  renderVetText(report)
1118
1291
 
1119
1292
  process.stdout.write(text + '\n')
1293
+
1294
+ if (true === args.strictCoverage && true === report.coverage?.vacuous) {
1295
+ process.stderr.write(
1296
+ 'aontu: no data leaf was constrained by the schema:' +
1297
+ ' this run checked nothing\n' +
1298
+ 'aontu: `aontu help language` — a map template is `&:`,' +
1299
+ ' and a quoted "*" is a key named *\n')
1300
+ return 1
1301
+ }
1120
1302
  return VET_EXIT[verdict]
1121
1303
  }
1122
1304
 
@@ -1144,19 +1326,6 @@ function sleep(ms: number): Promise<void> {
1144
1326
  }
1145
1327
 
1146
1328
 
1147
- // Resolve true when any watched file's signature moves off `before`.
1148
- // This is the real waiter: it never resolves false, so a real watch
1149
- // runs until the process is interrupted; tests inject their own waiter
1150
- // to bound the loop, and pass a short pollMs when they drive this one
1151
- // directly. The interval is a required argument (the command passes
1152
- // WATCH_POLL_MS) so there is no defaulting branch a test could never
1153
- // take.
1154
- //
1155
- // The BASELINE is an argument, not a snapshot taken here: the loop
1156
- // records it BEFORE each vet run, so a save landing between the run's
1157
- // reads and the wait still compares as a change. A waiter that
1158
- // snapshotted on entry would adopt that unvetted save as its baseline
1159
- // and wait indefinitely on a stale report.
1160
1329
  async function watchChange(
1161
1330
  files: string[], before: string, pollMs: number): Promise<boolean> {
1162
1331
  for (;;) {
@@ -1196,9 +1365,6 @@ async function watchVet(
1196
1365
  }
1197
1366
 
1198
1367
 
1199
- // The vet verb. Non-watch runs are synchronous and return the exit
1200
- // class directly; `--watch` returns a promise that resolves only when
1201
- // the waiter says stop (never, for the real one).
1202
1368
  function runVet(argv: string[], wait?: VetWaiter): number | Promise<number> {
1203
1369
  const trusted = takeTrust(argv)
1204
1370
  if (null == trusted) {
@@ -1376,14 +1542,6 @@ type BreakingArgs = {
1376
1542
  file: string
1377
1543
  against: string[]
1378
1544
  mode?: BreakingMode
1379
- // `--at`: compare a SUBTREE of both versions. The gate's own
1380
- // sub-question, and the one a real repository needs -- a document's
1381
- // top level carries the module's version string and its policy
1382
- // block, which are supposed to change between releases and which
1383
- // make the whole-document comparison answer about them rather than
1384
- // about the contract (use-cases/REVIEW.md finding D). `subsume` has
1385
- // taken it since G3; `breaking` did not, so the only way to gate a
1386
- // subtree was to split the file.
1387
1545
  at?: string
1388
1546
  allowUndecided: boolean
1389
1547
  allowDeprecatedRemoval: boolean
@@ -1467,36 +1625,10 @@ function parseBreakingArgs(
1467
1625
  }
1468
1626
  }
1469
1627
 
1470
- // One resolved `--against` spelling: the old document's text, the path
1471
- // its own relative includes must resolve from, and (for a git spelling)
1472
- // the temporary tree to remove when the run is done.
1473
1628
  type OldVersion = { src: string, path: string, temp?: string }
1474
1629
 
1475
- // A source file the include resolver can actually load. `git#<rev>`
1476
- // materialises these and nothing else: an include names an Aontu
1477
- // document (`.aon`/`.aontu`, the two extensions `@"foo"` tries) or a
1478
- // JSON one, so the rest of a revision's tree cannot be part of any
1479
- // include closure and copying it would be pure cost.
1480
1630
  const INCLUDABLE = /\.(aon|aontu|jsonic|json)$/
1481
1631
 
1482
- // Resolve one --against spelling to an old version.
1483
- //
1484
- // A `git#<rev>` spelling is the old version of the WHOLE TREE, not of
1485
- // the entry file alone. It used to be `git show <rev>:./<file>`, whose
1486
- // text was then evaluated with `generalPath`/`specificPath` pointing at
1487
- // the WORKING file -- so every `@"..."` include in the old document
1488
- // resolved against the working tree, and the "old" side was old entry
1489
- // text meeting new includes. A breaking change inside an included file
1490
- // therefore compared against itself and answered `compatible`: the
1491
- // documented CI gate silently un-gated every non-entry file of the
1492
- // multi-file layout real models use (use-cases/BUGS.md §26). The old
1493
- // tree's includable sources are copied into a temporary directory and
1494
- // the old document is evaluated from THERE.
1495
- //
1496
- // Sources outside the revision -- package includes under node_modules,
1497
- // the bundled `std/system` -- still resolve as they do today: they are
1498
- // not in the tree, and their versions travel with the lockfile rather
1499
- // than with this comparison.
1500
1632
  function oldVersion(spec: string, file: string): OldVersion | undefined {
1501
1633
  if (!spec.startsWith('git#')) {
1502
1634
  try {
@@ -1528,24 +1660,10 @@ function oldVersion(spec: string, file: string): OldVersion | undefined {
1528
1660
  // that only some failures take.
1529
1661
  const temp = mkdtempSync(join(tmpdir(), 'aontu-against-'))
1530
1662
  try {
1531
- // THE REPO-RELATIVE PATH COMES FROM GIT, not from path arithmetic.
1532
- // Relativising `rev-parse --show-toplevel` against `resolve(file)`
1533
- // puts two DIFFERENT COORDINATE SYSTEMS on either side of the
1534
- // subtraction: git prints the real path, while the caller's is
1535
- // whatever they typed. On macOS a temp file under /var is
1536
- // /private/var to git, and on Windows a TMP short name
1537
- // (RUNNER~1) is the long form to git -- so the subtraction gave a
1538
- // `../..` climb, the entry was "not in that revision", and the
1539
- // documented CI spelling failed on both platforms while passing on
1540
- // Linux (this PR's own CI). `--show-prefix` is the same question
1541
- // asked in git's coordinates: the repo-relative directory of the
1542
- // cwd, already slash-separated and already normalised.
1543
1663
  const prefix = git(['rev-parse', '--show-prefix'], dir).trim()
1544
1664
  const entryRel = prefix + basename(file)
1545
1665
  const top = git(['rev-parse', '--show-toplevel'], dir).trim()
1546
1666
 
1547
- // `-z` so a path with a newline or a quote cannot be mistaken for
1548
- // two paths (git otherwise quotes such names).
1549
1667
  const listed = git(['ls-tree', '-r', '-z', '--name-only', rev], top)
1550
1668
  .split('\0').filter((p) => '' !== p)
1551
1669
  if (!listed.includes(entryRel)) {
@@ -1580,13 +1698,6 @@ function policyCompat(
1580
1698
  ): BreakingMode | undefined {
1581
1699
  const aontu = new Aontu()
1582
1700
  const ctx = aontu.ctx({ collect: true })
1583
- // The declaration is read by EVALUATING the document, so this leg
1584
- // runs the include resolver too and has to run it under BOTH of the
1585
- // verb's include options -- a `breaking --trust none` that read its
1586
- // own mode through an unconfined resolver would confine the
1587
- // comparison and not the question (use-cases/REVIEW.md finding G),
1588
- // and one that took the capability alone read no mode at all when
1589
- // the declaration arrived through a `--text-ext` include.
1590
1701
  const v: any = aontu.unify(newSrc, { path, ...includeOpts(include) }, ctx)
1591
1702
  if (0 < ctx.err.length || true === v?.isNil) {
1592
1703
  return undefined
@@ -1606,11 +1717,6 @@ function policyCompat(
1606
1717
  ? m : undefined
1607
1718
  }
1608
1719
 
1609
- // Is the evaluated old version's value at the finding path deprecated?
1610
- // The --allow-deprecated-removal downgrade (G3 phase 4): removing (or
1611
- // otherwise changing) a value the old version already deprecated warns
1612
- // instead of breaking. The Go port exports the same reader as
1613
- // aontu.DeprecatedAt.
1614
1720
  function deprecatedAt(oldSrc: string, path: string, filePath: string): boolean {
1615
1721
  const aontu = new Aontu()
1616
1722
  const ctx = aontu.ctx({ collect: true })
@@ -1685,9 +1791,6 @@ function runBreaking(argv: string[]): number {
1685
1791
  return 2
1686
1792
  }
1687
1793
 
1688
- // The declared mode: --mode overrides the document's own policy;
1689
- // neither means backward, the index's framing (v1-valid documents
1690
- // stay valid).
1691
1794
  const mode: BreakingMode =
1692
1795
  args.mode ??
1693
1796
  policyCompat(newSrc, args.file,
@@ -1727,8 +1830,6 @@ function runBreaking(argv: string[]): number {
1727
1830
  temps.push(old.temp)
1728
1831
  }
1729
1832
 
1730
- // backward: the NEW document is the general side — every old
1731
- // instance must still be admitted. forward: the old one is.
1732
1833
  const checks: Array<{ general: [string, string], specific: [string, string] }> = []
1733
1834
  if ('backward' === mode || 'full' === mode) {
1734
1835
  checks.push({ general: [newSrc, args.file], specific: [oldSrc, spec] })
@@ -1745,18 +1846,10 @@ function runBreaking(argv: string[]): number {
1745
1846
  at: args.at,
1746
1847
  generalUrl: check.general[1],
1747
1848
  specificUrl: check.specific[1],
1748
- // The old side's relative loads resolve from ITS own tree --
1749
- // the materialised revision for a git spelling, the named
1750
- // file's directory otherwise -- so an included file's change
1751
- // is part of the comparison rather than invisible to it.
1752
1849
  generalPath: check.general[1] === spec ? oldPath : args.file,
1753
1850
  specificPath: check.specific[1] === spec ? oldPath : args.file,
1754
1851
  })
1755
1852
 
1756
- // The deprecated-removal downgrade: a finding about a value the
1757
- // OLD version already deprecated becomes a warning, and warnings
1758
- // do not move the verdict. Deprecate-then-remove is the
1759
- // supported rename path (the design's own sequencing).
1760
1853
  let verdict = report.verdict
1761
1854
  if (args.allowDeprecatedRemoval) {
1762
1855
  let liveFindings = 0
@@ -1814,13 +1907,6 @@ function renderBreakingJson(report: SubsumeReport, mode: string): string {
1814
1907
  }
1815
1908
 
1816
1909
 
1817
- // ---------------------------------------------------------------------
1818
- // The trim reporter (G3 phase 6): report redundant entries as paths.
1819
- // Report-only — REWRITING needs G7's format-preserving patch surface —
1820
- // which is why --check is REQUIRED rather than defaulted: `aontu trim
1821
- // f.aon` reads as "trim this file", and doing something else silently
1822
- // is worse than saying so.
1823
-
1824
1910
  const TRIM_HELP = 'aontu trim --check <file> (try --help)'
1825
1911
 
1826
1912
  const TRIM_EXIT: Record<TrimVerdict, number> = {
@@ -1898,8 +1984,6 @@ function runTrim(argv: string[]): number {
1898
1984
 
1899
1985
  function renderTrimText(report: TrimReport): string {
1900
1986
  const head = `verdict: ${report.verdict}`
1901
- // WHY, when the document could not be evaluated at all: rendered as
1902
- // vet renders a finding, because it IS one (the review's finding F).
1903
1987
  const errors = report.errors ?? []
1904
1988
  if (0 < errors.length) {
1905
1989
  return [head, ''].concat(errors.map(renderFinding)).join('\n')
@@ -1964,29 +2048,12 @@ const VIEW_EDGES: ViewEdges[] = ['upward', 'all', 'none']
1964
2048
  // the same division err.ts already draws for the error frames.
1965
2049
  const VIEW_STYLES = ['auto', 'none', 'ansi', 'css']
1966
2050
 
1967
- // `--style auto` resolved, which only the CLI can do. The mechanism is
1968
- // the PROFILE's and the library knows it -- an SVG carries its
1969
- // stylesheet unless told not to, which is what makes a figure stand
1970
- // alone. What the library cannot know is whether the DESTINATION is a
1971
- // terminal, so that is the only thing decided here: escapes on the
1972
- // text profile when stdout is a terminal and NO_COLOR is unset, the
1973
- // same two conditions the error frames use. `undefined` leaves the
1974
- // profile's own default in place.
1975
2051
  function viewStyleOf(
1976
2052
  asked: string | undefined, as: ViewProfile | undefined
1977
2053
  ): ViewStyle | undefined {
1978
2054
  if (undefined !== asked && 'auto' !== asked) {
1979
2055
  return asked as ViewStyle
1980
2056
  }
1981
- // STDOUT'S OWN TERMINAL-NESS, and NO_COLOR read here rather than
1982
- // through colorActive(). The figure goes to STDOUT and the error
1983
- // frames go to STDERR, and they are not the same destination: main()
1984
- // has already called setColor for stderr, so asking colorActive()
1985
- // would answer the wrong question twice --- no escapes for
1986
- // `aontu view tree m.aon 2>/dev/null` at a terminal, and escapes
1987
- // into the pipe for `aontu view tree m.aon | less`. The NO_COLOR
1988
- // rule is the one no-color.org states and err.ts implements:
1989
- // set, to anything but empty, means no colour.
1990
2057
  const no = process.env.NO_COLOR
1991
2058
  return 'text' === as && true === process.stdout.isTTY
1992
2059
  && (null == no || '' === no) ? 'ansi' : undefined
@@ -2012,24 +2079,6 @@ const VIEW_USAGE_CODES = [
2012
2079
 
2013
2080
  const MOD_HELP = 'aontu mod tidy|verify|vendor|manifest [dir] (try --help)'
2014
2081
 
2015
- // The module tooling (G6 phase 3, ts/src/mod-tool.ts). All LOCAL:
2016
- // `tidy` resolves the closure from what is in the stores and rewrites
2017
- // the lockfile, `verify` asks whether the stores still mean what the
2018
- // lockfile pins and changes nothing, `vendor` materialises the locked
2019
- // closure into the project, `manifest` prints what a publish would
2020
- // push.
2021
- //
2022
- // TIDY AND VERIFY ARE DIFFERENT QUESTIONS, and that is why both exist.
2023
- // Tidy recomputes and rewrites by design -- a pin is what a module
2024
- // means NOW -- so it makes the lockfile agree with whatever the store
2025
- // holds, tampering included. Verify is the gate: a CI job runs it
2026
- // BEFORE tidy, or instead of it.
2027
- //
2028
- // `get` and `publish` are the NETWORK half of the design and are not in
2029
- // this build. They are named here rather than left to fall out as an
2030
- // unknown subcommand, because a reader of the design will type them and
2031
- // deserves to be told which half is missing rather than that the word
2032
- // is wrong.
2033
2082
  function runMod(argv: string[]): number {
2034
2083
  const rest: string[] = []
2035
2084
  let format: SubsumeFormat = 'text'
@@ -2083,10 +2132,6 @@ function runMod(argv: string[]): number {
2083
2132
  return 2
2084
2133
  }
2085
2134
 
2086
- // THE OLD LAYOUT IS NAMED, NOT READ. The lockfile and the vendored
2087
- // closure moved under aontu_meta/; a project that still carries them
2088
- // at its root would otherwise look untouched by any of these verbs,
2089
- // which is the one silence worth breaking.
2090
2135
  if (existsSync(join(dir, 'aon_vendor')) || existsSync(join(dir, 'mod-lock.aon'))) {
2091
2136
  process.stderr.write(
2092
2137
  'aontu: aon_vendor/ and mod-lock.aon now live under aontu_meta/: ' +
@@ -2130,9 +2175,6 @@ type ModVerdict =
2130
2175
  const MOD_EXIT: Record<ModVerdict, number> = {
2131
2176
  ok: 0,
2132
2177
  missing: 1,
2133
- // A REFUSED GATE, with `breaking`: a store that no longer means what
2134
- // the lockfile pins is the integrity check saying no, and a CI job
2135
- // reading exit codes should not have to learn a third class for it.
2136
2178
  mismatch: 1,
2137
2179
  // Likewise a lockfile that does not cover the project: the gate has
2138
2180
  // nothing to check, which is a refusal and not a pass.
@@ -2183,11 +2225,6 @@ function modText(sub: string, report: any): string {
2183
2225
  for (const f of report.findings) {
2184
2226
  lines.push(f.path + ': ' + f.message)
2185
2227
  }
2186
- // What a manifest lacks is a declaration the module does not make
2187
- // or an entry file that is not there, and neither is something a
2188
- // fetch would supply -- so this is not the tail the other two
2189
- // subcommands share. The name says which kind it is: `mod.version`
2190
- // is a declaration, `service.aon` is a file.
2191
2228
  for (const miss of report.missing) {
2192
2229
  lines.push(miss + ': missing')
2193
2230
  }
@@ -2198,8 +2235,6 @@ function modText(sub: string, report: any): string {
2198
2235
  for (const mod of report.verified) {
2199
2236
  lines.push(mod + ': verified')
2200
2237
  }
2201
- // BOTH HASHES, because the useful question is which way it moved:
2202
- // an empty `got` is a module that no longer stands up at all.
2203
2238
  for (const m of report.mismatched) {
2204
2239
  lines.push(m.mod + ': pinned ' + m.want + ' but the store means ' +
2205
2240
  ('' === m.got ? 'nothing (it does not evaluate)' : m.got))
@@ -2235,6 +2270,11 @@ function modText(sub: string, report: any): string {
2235
2270
  }
2236
2271
 
2237
2272
 
2273
+ function vacuous(what: string, why: string): void {
2274
+ process.stderr.write(`aontu: ${what}: ${why}\n`)
2275
+ }
2276
+
2277
+
2238
2278
  function runRelations(argv: string[]): number {
2239
2279
  const trusted = takeTrust(argv)
2240
2280
  if (null == trusted) {
@@ -2283,12 +2323,21 @@ function runRelations(argv: string[]): number {
2283
2323
  }
2284
2324
 
2285
2325
  const report = relationCheck(src, {
2286
- path: files[0], ...verbOpts(trust, entryRootOf(files[0])),
2326
+ path: files[0], count: true,
2327
+ ...verbOpts(trust, entryRootOf(files[0])),
2287
2328
  })
2288
2329
  const text = 'json' === format
2289
2330
  ? renderRelationsJson(report)
2290
2331
  : renderRelationsText(report)
2291
2332
  process.stdout.write(text + '\n')
2333
+ // `pass` over NO declarations is the vacuous case, and the engine
2334
+ // knows it exactly: `_reldecls` is empty. The count is asked for
2335
+ // here rather than derived, so the answer costs no second
2336
+ // evaluation.
2337
+ if (0 === report.declared) {
2338
+ vacuous('this document declares no relations',
2339
+ '`pass` means nothing was checked, not that the graph is sound')
2340
+ }
2292
2341
  return RELATIONS_EXIT[report.verdict]
2293
2342
  }
2294
2343
 
@@ -2508,12 +2557,6 @@ function runView(argv: string[]): number {
2508
2557
  }
2509
2558
  }
2510
2559
 
2511
- // ESCAPES NEVER GO INTO A FILE. A pinned golden holding terminal
2512
- // control codes is not a golden anybody can read, and a byte
2513
- // comparison against one would fail on the reader's terminal
2514
- // settings. `auto` resolves to `none` there on its own; asking for
2515
- // `ansi` explicitly is a usage error rather than a silent downgrade,
2516
- // so a script that wanted colour is told where it went.
2517
2560
  if ('ansi' === style && (undefined !== out || undefined !== opts.views)) {
2518
2561
  process.stderr.write(
2519
2562
  'aontu: --style ansi writes to a terminal, not to a file\n')
@@ -2523,10 +2566,6 @@ function runView(argv: string[]): number {
2523
2566
  // THE VIEW DOCUMENT draws every figure a document declares, so it
2524
2567
  // names no kind: the declarations do, one each.
2525
2568
  if (undefined !== opts.views) {
2526
- // A declaration names its own profile, so the style is left to
2527
- // each figure's own default; `--style none` still reaches every
2528
- // one of them, which is how a host page that binds the CSS
2529
- // variables asks for eight figures without eight stylesheets.
2530
2569
  opts.style = viewStyleOf(style, undefined)
2531
2570
  return runViewSet(rest, opts, trust, { format, check, strict, out })
2532
2571
  }
@@ -2591,7 +2630,7 @@ function runView(argv: string[]): number {
2591
2630
  }
2592
2631
  }
2593
2632
 
2594
- const report = view(srcs[0], {
2633
+ const viewOpts = {
2595
2634
  ...opts,
2596
2635
  style: viewStyleOf(style, opts.as ?? viewDefaultProfile(kind)),
2597
2636
  kind,
@@ -2599,7 +2638,17 @@ function runView(argv: string[]): number {
2599
2638
  roots,
2600
2639
  ...verbOpts(trust, entryRootOf(files[0])),
2601
2640
  docs: files.slice(1).map((path, i) => ({ src: srcs[i + 1], path })),
2602
- })
2641
+ }
2642
+ const report = view(srcs[0], viewOpts)
2643
+
2644
+ if ('error' !== report.verdict && null != report.text) {
2645
+ const bare = view('{}', viewOpts)
2646
+ if ('error' !== bare.verdict && bare.text === report.text) {
2647
+ vacuous('nothing to draw',
2648
+ 'this figure is what the same view draws for an empty document' +
2649
+ ' — the model declares nothing this kind can show')
2650
+ }
2651
+ }
2603
2652
 
2604
2653
  if ('json' === format) {
2605
2654
  process.stdout.write(renderViewJson(report) + '\n')
@@ -2645,13 +2694,6 @@ function runView(argv: string[]): number {
2645
2694
  return strict && 'lossy' === report.verdict ? 1 : VIEW_EXIT[report.verdict]
2646
2695
  }
2647
2696
 
2648
- // `aontu view --views <path> <file>`: every figure the document
2649
- // declares, from one evaluation, all or nothing.
2650
- //
2651
- // A declared `out` is resolved against the DOCUMENT's own directory,
2652
- // not the caller's: a view document is committed beside the figures it
2653
- // gates, and a gate that only passes from one working directory is not
2654
- // a gate.
2655
2697
  function runViewSet(
2656
2698
  rest: string[], opts: ViewOptions, trust: TrustArg,
2657
2699
  how: { format: SubsumeFormat, check: boolean, strict: boolean, out?: string }
@@ -2792,8 +2834,6 @@ function renderViewJson(report: ViewReport): string {
2792
2834
 
2793
2835
  function renderRelationsText(report: RelationReport): string {
2794
2836
  const head = `verdict: ${report.verdict}`
2795
- // WHY, when the document could not be evaluated at all: rendered as
2796
- // vet renders a finding, because it IS one (the review's finding F).
2797
2837
  const errors = report.errors ?? []
2798
2838
  if (0 < errors.length) {
2799
2839
  return [head, ''].concat(errors.map(renderFinding)).join('\n')
@@ -2819,21 +2859,6 @@ function renderRelationsJson(report: RelationReport): string {
2819
2859
  }
2820
2860
 
2821
2861
 
2822
-
2823
- // ---------------------------------------------------------------------
2824
- // JSON SCHEMA EXPORT (SUPPORT.md act 2, the review's finding I): the
2825
- // bridge to every structured-output API, which constrains generation to
2826
- // JSON Schema and nothing else. Export the model, let the provider
2827
- // generate under it, then `vet` the result against the model itself --
2828
- // the hybrid an enterprise actually deploys, and impossible without
2829
- // this verb.
2830
- //
2831
- // THE SCHEMA GOES TO STDOUT AND THE LOSSES TO STDERR, so `aontu
2832
- // jsonschema x.aon > schema.json` writes a schema and still tells the
2833
- // reader what it could not carry. `--strict` makes a loss a refusal,
2834
- // for the CI job that would rather fail than ship a schema weaker than
2835
- // its model.
2836
-
2837
2862
  const JSONSCHEMA_HELP =
2838
2863
  'aontu jsonschema [--at <path>] [--strict] <file> (try --help)'
2839
2864
 
@@ -2929,14 +2954,6 @@ function runJsonSchema(argv: string[]): number {
2929
2954
  strict && 'lossy' === report.verdict ? 1 : 0
2930
2955
  }
2931
2956
 
2932
- // ---------------------------------------------------------------------
2933
- // THE RENDER VERB (docs/design/RENDER.0.md D8): evaluate a document,
2934
- // vet the value at --at against aontu:code, fold code.units into bytes,
2935
- // and put them where the flag says -- one unit on stdout, every unit
2936
- // below --out (all or nothing), or compared against --check. Exit codes
2937
- // mirror jsonschema's: 0 ok; 1 lossy under --strict or drift under
2938
- // --check; 2 usage or I/O, a refused unit path included; 4 the
2939
- // document does not stand up or the instance is not aontu:code.
2940
2957
 
2941
2958
  const RENDER_HELP =
2942
2959
  'aontu render [--at <path>] [--profile <file>]... [--unit <path>] ' +
@@ -3074,49 +3091,22 @@ function runRender(argv: string[]): number {
3074
3091
  return 2
3075
3092
  }
3076
3093
 
3077
- // THE ENTRY MAY BE A TEMPLATE (TEMPLATE.0.md; P8), and its EXTENSION
3078
- // decides, as an include's extension decides what the include is
3079
- // (ADR-012): a generator is a file in the target's own syntax, so it
3080
- // carries the target's extension and never `.aon`. Desugared here
3081
- // rather than anywhere deeper, because a template is an entry
3082
- // spelling and not a value: an include is still aontu.
3083
- if (!files[0].endsWith('.aon')) {
3084
- src = desugarTemplate(src, marker ?? markerFor(files[0]))
3094
+ const loadedProfiles = loadProfiles(profileFiles, trust)
3095
+ if ('number' === typeof loadedProfiles) {
3096
+ return loadedProfiles
3085
3097
  }
3098
+ const profiles = loadedProfiles
3086
3099
 
3087
- // THE PROFILES (D5): each --profile file is a document whose root is
3088
- // `profile: {lang, ...}`, evaluated under the verb's trust and vetted
3089
- // against aontu:profile as a settled value before the fold reads it
3090
- // (renderProfile, which also fills the defaults). Two files claiming
3091
- // one lang is a usage error: the fold could not choose.
3092
- const profiles: any[] = []
3093
- const langs = new Map<string, string>()
3094
- for (const pf of profileFiles) {
3095
- let text: string
3096
- try {
3097
- text = readFileSync(pf, 'utf8')
3098
- }
3099
- catch (err: any) {
3100
- process.stderr.write(`aontu: cannot read ${err.path}: ${err.message}\n`)
3101
- return 2
3102
- }
3103
- const loaded = renderProfile(text,
3104
- { path: resolve(pf), ...verbOpts(trust, entryRootOf(pf)) })
3105
- if (undefined !== loaded.errors) {
3106
- process.stderr.write(loaded.errors.map(renderFinding).join('\n') + '\n')
3107
- return 4
3108
- }
3109
- const profile = loaded.profile
3110
- const prev = langs.get(profile.lang)
3111
- if (undefined !== prev) {
3112
- process.stderr.write(
3113
- `aontu: two profiles claim ${profile.lang}: ${prev} and ${pf}\n`)
3114
- return 2
3115
- }
3116
- langs.set(profile.lang, pf)
3117
- profiles.push(profile)
3100
+ if (!files[0].endsWith('.aon')) {
3101
+ src = desugarTemplate(src, marker ??
3102
+ markerFromProfiles(profiles, files[0]) ?? markerFor(files[0]))
3118
3103
  }
3119
3104
 
3105
+ // A RENDER WITH NO PROFILE PRODUCES NO UNITS, and said so with zero
3106
+ // bytes and exit 0. The profile is what maps a model onto a
3107
+ // language, so without one there is nothing for the renderer to
3108
+ // write -- which is a usable answer only if the caller is told.
3109
+ const noProfiles = 0 === profiles.length
3120
3110
  const report = render(src, {
3121
3111
  at, unit, strict, profiles, path: files[0],
3122
3112
  coverage, coverageAt,
@@ -3127,6 +3117,15 @@ function runRender(argv: string[]): number {
3127
3117
  ...verbOpts(trust, entryRootOf(files[0])),
3128
3118
  })
3129
3119
 
3120
+ // Said once, whatever the format: stdout stays the report.
3121
+ if ('error' !== report.verdict && 0 === report.units.length) {
3122
+ vacuous('nothing was rendered',
3123
+ noProfiles
3124
+ ? 'no profile was given, and the document declares none' +
3125
+ ' (see aontu help tasks)'
3126
+ : 'the document produced no units under this profile')
3127
+ }
3128
+
3130
3129
  if ('json' === format) {
3131
3130
  process.stdout.write(exactJSON({
3132
3131
  aontu: { version: version(), verb: 'render' },
@@ -3252,21 +3251,21 @@ function renderExit(report: RenderReport, drift: number): number {
3252
3251
  }
3253
3252
 
3254
3253
 
3255
- // ---------------------------------------------------------------------
3256
- // THE TEMPLATE SURFACE (docs/design/TEMPLATE.0.md; RENDER.0.md P8): the
3257
- // two transforms and the round trip between them. `render` reads a
3258
- // template directly, by its extension; this verb is for seeing the
3259
- // canonical form, for writing one by hand and sugaring it, and for the
3260
- // check that keeps a committed template and its meaning in agreement.
3261
-
3262
3254
  const TEMPLATE_HELP =
3263
3255
  'aontu template [--resugar] [--check] [--marker <token>] <file> (try --help)'
3264
3256
 
3265
3257
  function runTemplate(argv: string[]): number {
3258
+ const trusted = takeTrust(argv)
3259
+ if (null == trusted) {
3260
+ return 2
3261
+ }
3262
+ argv = trusted.argv
3263
+ const trust = trusted.trust
3266
3264
  const files: string[] = []
3267
3265
  let resugar = false
3268
3266
  let check = false
3269
3267
  let marker: string | undefined = undefined
3268
+ const profileFiles: string[] = []
3270
3269
 
3271
3270
  for (let i = 0; i < argv.length; i++) {
3272
3271
  const arg = argv[i]
@@ -3287,6 +3286,14 @@ function runTemplate(argv: string[]): number {
3287
3286
  return 2
3288
3287
  }
3289
3288
  }
3289
+ else if ('--profile' === arg) {
3290
+ const pf = argv[++i]
3291
+ if (null == pf) {
3292
+ process.stderr.write('aontu: --profile needs a file\n')
3293
+ return 2
3294
+ }
3295
+ profileFiles.push(pf)
3296
+ }
3290
3297
  else if (arg.startsWith('-')) {
3291
3298
  process.stderr.write(
3292
3299
  `aontu: unknown template option ${arg} (try --help)\n`)
@@ -3301,10 +3308,6 @@ function runTemplate(argv: string[]): number {
3301
3308
  process.stderr.write(`aontu: template needs one file\n${TEMPLATE_HELP}\n`)
3302
3309
  return 2
3303
3310
  }
3304
- // THE TWO ARE DIRECTIONS, NOT MODES THAT COMPOSE: `--check` reads a
3305
- // template and asks whether the round trip answers it back, and
3306
- // `--resugar` reads the canonical form instead. A run cannot be both
3307
- // at once, because the file is one thing or the other.
3308
3311
  if (resugar && check) {
3309
3312
  process.stderr.write(
3310
3313
  'aontu: template takes one of --resugar or --check\n')
@@ -3320,19 +3323,15 @@ function runTemplate(argv: string[]): number {
3320
3323
  return 2
3321
3324
  }
3322
3325
 
3323
- const mark = marker ?? markerFor(files[0])
3326
+ const declared = loadProfiles(profileFiles, trust)
3327
+ if ('number' === typeof declared) {
3328
+ return declared
3329
+ }
3330
+
3331
+ const mark = marker ?? markerFromProfiles(declared, files[0]) ??
3332
+ markerFor(files[0])
3324
3333
 
3325
3334
  if (check) {
3326
- // THE ROUND TRIP IS THE CHECK (D6): the file held to the spelling
3327
- // the two transforms answer. What that names is a marker line the
3328
- // transform would not have written -- one without its space, or one
3329
- // whose aontu is indented after the marker rather than before it,
3330
- // since the marker keeps its own indentation. It does NOT name a
3331
- // changed body line: a template's whitespace is output, so a
3332
- // trimmed trailing space is still a valid template and it is
3333
- // `render --check` against the committed files that catches it.
3334
- // The first line that differs is the report, since a whole diff of
3335
- // a generator is the file again.
3336
3335
  const back = resugarTemplate(desugarTemplate(src, mark), mark)
3337
3336
  if (back === src) {
3338
3337
  return 0
@@ -3343,12 +3342,6 @@ function runTemplate(argv: string[]): number {
3343
3342
  while (n < want.length && n < have.length && want[n] === have[n]) {
3344
3343
  n++
3345
3344
  }
3346
- // THE TWO ARE THE SAME LENGTH, always: each transform maps one
3347
- // line to one line and applies the same trailing-newline rule, so
3348
- // `back` has as many lines as `src`. The loop above therefore stops
3349
- // at a real difference rather than by running out of either -- an
3350
- // equal prefix all the way to the end IS `back === src`, which
3351
- // returned above. So both indexes are in range here.
3352
3345
  process.stderr.write(
3353
3346
  `aontu: ${files[0]}:${n + 1} is not what the round trip answers\n` +
3354
3347
  ` have: ${JSON.stringify(have[n])}\n` +
@@ -3362,13 +3355,43 @@ function runTemplate(argv: string[]): number {
3362
3355
  }
3363
3356
 
3364
3357
 
3365
- // ---------------------------------------------------------------------
3366
- // The canon-hash (G6 phase 1): the pin an agent, a lockfile or a
3367
- // registry stores for "this module, this meaning". The hash covers the
3368
- // module evaluated STANDALONE -- its own include closure resolved and
3369
- // unified at its own root, before any consumer context -- which is what
3370
- // makes the pin transitive: an edit two includes deep changes the
3371
- // unified root, hence the hash.
3358
+ // The profiles named by --profile, vetted, or the exit code that says
3359
+ // why not. A profile is a language declared as data: `render` matches
3360
+ // one to a unit by `lang`, and `template` and `fmt` match one to a file
3361
+ // by the extensions its `template.ext` names.
3362
+ function loadProfiles(
3363
+ profileFiles: string[], trust: TrustArg
3364
+ ): any[] | number {
3365
+ const profiles: any[] = []
3366
+ const langs = new Map<string, string>()
3367
+ for (const pf of profileFiles) {
3368
+ let text: string
3369
+ try {
3370
+ text = readFileSync(pf, 'utf8')
3371
+ }
3372
+ catch (err: any) {
3373
+ process.stderr.write(`aontu: cannot read ${err.path}: ${err.message}\n`)
3374
+ return 2
3375
+ }
3376
+ const loaded = renderProfile(text,
3377
+ { path: resolve(pf), ...verbOpts(trust, entryRootOf(pf)) })
3378
+ if (undefined !== loaded.errors) {
3379
+ process.stderr.write(loaded.errors.map(renderFinding).join('\n') + '\n')
3380
+ return 4
3381
+ }
3382
+ const profile = loaded.profile
3383
+ const prev = langs.get(profile.lang)
3384
+ if (undefined !== prev) {
3385
+ process.stderr.write(
3386
+ `aontu: two profiles claim ${profile.lang}: ${prev} and ${pf}\n`)
3387
+ return 2
3388
+ }
3389
+ langs.set(profile.lang, pf)
3390
+ profiles.push(profile)
3391
+ }
3392
+ return profiles
3393
+ }
3394
+
3372
3395
 
3373
3396
  const HASH_HELP = 'aontu hash <file> (try --help)'
3374
3397
 
@@ -3429,14 +3452,6 @@ function runHash(argv: string[]): number {
3429
3452
  const ctx = aontu.ctx({ collect: true })
3430
3453
  const v: any = aontu.unify(src, { path: files[0] }, ctx)
3431
3454
  if (0 < ctx.err.length || true === v?.isNil) {
3432
- // A document that does not stand up on its own has no meaning to
3433
- // pin, and a hash of a broken evaluation would be a pin that
3434
- // silently agrees with every other broken evaluation.
3435
- // WHY it does not stand up, not just that it does not: the same
3436
- // diagnosis `aontu <file>` prints (the review's finding F).
3437
- // evalFailure unconditionally, as every other call site does: it
3438
- // owns the "ctx.err is never empty here" contract, and a guard
3439
- // that pretends otherwise is a dead arm asserting nothing.
3440
3455
  process.stderr.write(
3441
3456
  `aontu: ${files[0]} does not evaluate on its own; nothing to hash\n` +
3442
3457
  renderFinding(evalFailure(ctx)) + '\n')
@@ -3455,13 +3470,6 @@ function runHash(argv: string[]): number {
3455
3470
  }
3456
3471
 
3457
3472
 
3458
- // ---------------------------------------------------------------------
3459
- // The query surface (G7 phase 1): one node of an evaluated document,
3460
- // selected by path and rendered. Evaluation is still GLOBAL -- what
3461
- // `get` buys is the size of the ANSWER, not the cost of producing it --
3462
- // and the projections are lattice abstractions, each a valid Aontu
3463
- // document that subsumes the truth it summarises.
3464
-
3465
3473
  const GET_HELP = 'aontu get <path> <file> (try --help)'
3466
3474
 
3467
3475
  function runGet(argv: string[]): number {
@@ -3649,11 +3657,6 @@ function runWhy(argv: string[]): number {
3649
3657
  }
3650
3658
 
3651
3659
 
3652
- // One contribution per line, numbered in source order, each with what
3653
- // was written, where, and how it got here. A siteless contribution
3654
- // prints no location rather than a `-1:-1` that means nothing —
3655
- // exported for the direct test, because the site SHAPE allows one
3656
- // while no document has yet produced one (ADR-002).
3657
3660
  function renderWhyText(record: WhyRecord): string {
3658
3661
  const head = `${record.path} = ${record.value}`
3659
3662
  if (0 === record.conjuncts.length) {
@@ -3671,13 +3674,6 @@ function renderWhyText(record: WhyRecord): string {
3671
3674
  }
3672
3675
 
3673
3676
 
3674
- // ---------------------------------------------------------------------
3675
- // The overlay patch verb (G7 phase 5): change a document by APPENDING
3676
- // to an overlay, not by rewriting it. An overlay entry is just another
3677
- // conjunct and unification is order-independent, so this needs no
3678
- // rewriter — the format-preserving in-place edit is stage 2, and needs
3679
- // a comment-preserving CST the parser stack does not have.
3680
-
3681
3677
  const SET_HELP =
3682
3678
  'aontu set <path>=<value> --entry <file> --overlay <file> (try --help)'
3683
3679
 
@@ -3794,29 +3790,12 @@ function runSet(argv: string[]): number {
3794
3790
  }, 2) + '\n')
3795
3791
  }
3796
3792
  else {
3797
- // A replacement is REPORTED as the edit it is, not left for the
3798
- // reader to infer from a changed file: `where: what -> what`, in
3799
- // source spelling, because the spelling is what changed.
3800
- //
3801
- // PAST TENSE ONLY WHERE IT HAPPENED. A refused write leaves the
3802
- // file exactly as it was, and one assignment can be replaceable
3803
- // while another makes the whole run invalid — so `replaced:` there
3804
- // tells an operator the pin was changed when it was not, and unlike
3805
- // `--dry-run` there is nothing else on the line to say otherwise.
3806
3793
  const verb = wrote ? 'replaced' : 'would replace'
3807
3794
  const edits = report.replaced.map((r) =>
3808
3795
  `${verb}: ${r.file}:${r.row}:${r.col} ${r.from} -> ${r.to}`)
3809
3796
  const head = [`verdict: ${report.verdict}`].concat(edits).join('\n') +
3810
3797
  (wrote ? `\nwrote: ${overlayFile}` : dryRun ? '\n(dry run)' : '')
3811
3798
 
3812
- // A SUCCESSFUL COMMAND WRITES ITS STATUS TO STDOUT, findings or
3813
- // not. Routing on `findings.length` was right while every finding
3814
- // this verb could produce was an ERROR; `--in-place` made a WARNING
3815
- // possible, and a run that held, wrote the file and exited 0 then
3816
- // sent its whole report to stderr — leaving stdout empty, so
3817
- // `$(aontu set ...)` captured nothing and only the JSON form
3818
- // behaved like a success. The verdict decides the stream; warnings
3819
- // are diagnostics and go to stderr beside it.
3820
3799
  const failed = 'invalid' === report.verdict || 'error' === report.verdict
3821
3800
  const findingText = report.findings.map(renderFinding)
3822
3801
  if (failed) {
@@ -3838,6 +3817,171 @@ function runSet(argv: string[]): number {
3838
3817
  }
3839
3818
 
3840
3819
 
3820
+ const ALLOW_HELP =
3821
+ 'aontu allow --role <role> <roles-file> <path> [more-paths...] (try --help)'
3822
+
3823
+ const ALLOW_EXIT: Record<AllowVerdict, number> = {
3824
+ allowed: 0,
3825
+ refused: 1,
3826
+ error: 4,
3827
+ }
3828
+
3829
+
3830
+ // One line per asked path: the answer, and the entry that gave it, as
3831
+ // a path into the role model so `aontu why` can locate the rule.
3832
+ function renderAllowDecision(d: AllowDecision, role: string): string {
3833
+ const head = `${d.path}: ${d.allowed ? 'allowed' : 'refused'}`
3834
+ switch (d.reason) {
3835
+ case 'allow':
3836
+ case 'deny':
3837
+ return `${head} by ${d.by} (${d.pattern})`
3838
+ case 'uncovered':
3839
+ return `${head} (no allow entry of ${role} covers it)`
3840
+ default:
3841
+ return `${head} (role ${role} is not declared)`
3842
+ }
3843
+ }
3844
+
3845
+
3846
+ function renderAllowText(report: AllowReport): string {
3847
+ const lines = [`verdict: ${report.verdict}`, `role: ${report.role}`]
3848
+ .concat(report.paths.map((d) => renderAllowDecision(d, report.role)))
3849
+ if (0 === report.findings.length) {
3850
+ return lines.join('\n')
3851
+ }
3852
+ return lines.concat('', report.findings.map(renderFinding)).join('\n')
3853
+ }
3854
+
3855
+
3856
+ // Does the text after `=` parse as exactly one value? Parsed, never
3857
+ // evaluated, with loads denied: the question is the shape of the
3858
+ // argument, and reading a file to answer it would be the write the
3859
+ // gate exists to precede.
3860
+ function oneValue(value: string): boolean {
3861
+ try {
3862
+ const probe: any = new Aontu({ trust: { include: 'none' } })
3863
+ .parse('v: ' + value)
3864
+ return 1 === Object.keys(probe.peg).length
3865
+ }
3866
+ catch {
3867
+ return false
3868
+ }
3869
+ }
3870
+
3871
+
3872
+ function runAllow(argv: string[]): number {
3873
+ const trusted = takeTrust(argv)
3874
+ if (null == trusted) {
3875
+ return 2
3876
+ }
3877
+ argv = trusted.argv
3878
+ const trust = trusted.trust
3879
+ const rest: string[] = []
3880
+ let role: string | undefined
3881
+ let at: string | undefined
3882
+ let format: SubsumeFormat = 'text'
3883
+
3884
+ for (let i = 0; i < argv.length; i++) {
3885
+ const arg = argv[i]
3886
+ if ('-h' === arg || '--help' === arg) {
3887
+ process.stdout.write(HELP)
3888
+ return 0
3889
+ }
3890
+ if ('--role' === arg) {
3891
+ role = argv[++i]
3892
+ if (null == role) {
3893
+ process.stderr.write('aontu: --role needs a role name\n')
3894
+ return 2
3895
+ }
3896
+ }
3897
+ else if ('--at' === arg) {
3898
+ at = argv[++i]
3899
+ if (null == at) {
3900
+ process.stderr.write('aontu: --at needs a path\n')
3901
+ return 2
3902
+ }
3903
+ }
3904
+ else if ('--format' === arg) {
3905
+ const f = argv[++i]
3906
+ if ('text' !== f && 'json' !== f) {
3907
+ process.stderr.write('aontu: --format needs text or json\n')
3908
+ return 2
3909
+ }
3910
+ format = f
3911
+ }
3912
+ else if (arg.startsWith('-')) {
3913
+ process.stderr.write(`aontu: unknown allow option ${arg} (try --help)\n`)
3914
+ return 2
3915
+ }
3916
+ else {
3917
+ rest.push(arg)
3918
+ }
3919
+ }
3920
+
3921
+ if (null == role || rest.length < 2) {
3922
+ process.stderr.write(
3923
+ `aontu: allow needs --role, a role model and at least one path\n` +
3924
+ `${ALLOW_HELP}\n`)
3925
+ return 2
3926
+ }
3927
+ const [file, ...asked] = rest
3928
+
3929
+ // A role is ONE KEY of the roles map. A dotted name would be read as
3930
+ // a path by `why` when it follows the entry the report names, and an
3931
+ // empty one names the map itself.
3932
+ if ('' === role || role.includes('.')) {
3933
+ process.stderr.write('aontu: --role needs one key, without dots\n')
3934
+ return 2
3935
+ }
3936
+
3937
+ const paths: string[] = []
3938
+ for (const arg of asked) {
3939
+ const eq = arg.indexOf('=')
3940
+ const path = eq < 0 ? arg : arg.slice(0, eq)
3941
+ if (!path.startsWith('$')) {
3942
+ process.stderr.write(
3943
+ `aontu: a path starts with $ (got ${JSON.stringify(arg)})\n`)
3944
+ return 2
3945
+ }
3946
+ if (0 <= eq && !oneValue(arg.slice(eq + 1))) {
3947
+ process.stderr.write(
3948
+ `aontu: the value of ${path} is not one value\n`)
3949
+ return 2
3950
+ }
3951
+ paths.push(path)
3952
+ }
3953
+
3954
+ let src: string
3955
+ try {
3956
+ src = readFileSync(file, 'utf8')
3957
+ }
3958
+ catch (err: any) {
3959
+ process.stderr.write(`aontu: cannot read ${err.path}: ${err.message}\n`)
3960
+ return 2
3961
+ }
3962
+
3963
+ const report = allow(src, role, paths, {
3964
+ at, path: file, ...verbOpts(trust, entryRootOf(file)),
3965
+ })
3966
+
3967
+ const text = 'json' === format ?
3968
+ exactJSON({
3969
+ aontu: { version: version(), verb: 'allow' },
3970
+ findings: report.findings,
3971
+ paths: report.paths,
3972
+ role: report.role,
3973
+ verdict: report.verdict,
3974
+ }, 2) :
3975
+ renderAllowText(report)
3976
+
3977
+ // The report IS the answer, refused or not, so it goes to stdout as
3978
+ // vet's does; the exit code carries the verdict for a caller that
3979
+ // reads nothing else.
3980
+ process.stdout.write(text + '\n')
3981
+ return ALLOW_EXIT[report.verdict]
3982
+ }
3983
+
3984
+
3841
3985
  // ---------------------------------------------------------------------
3842
3986
  // The generated AGENTS.md stanza (G7 phase 6): the prose entrypoint,
3843
3987
  // derived from the definition, so it cannot drift from the formal
@@ -3854,6 +3998,10 @@ function runAgentsMd(argv: string[]): number {
3854
3998
  const trust = trusted.trust
3855
3999
  const files: string[] = []
3856
4000
  let write: string | undefined
4001
+ // The SHAPE's depth (G11 phase 7). Default 2, unchanged: the stanza
4002
+ // is spliced into a file people read, and a deeper shape is a
4003
+ // question the caller asks rather than one it is handed.
4004
+ let depth = 2
3857
4005
 
3858
4006
  for (let i = 0; i < argv.length; i++) {
3859
4007
  const arg = argv[i]
@@ -3868,6 +4016,14 @@ function runAgentsMd(argv: string[]): number {
3868
4016
  return 2
3869
4017
  }
3870
4018
  }
4019
+ else if ('--depth' === arg) {
4020
+ const n = Number(argv[++i])
4021
+ if (!Number.isInteger(n) || n < 1) {
4022
+ process.stderr.write('aontu: --depth needs a positive integer\n')
4023
+ return 2
4024
+ }
4025
+ depth = n
4026
+ }
3871
4027
  else if (arg.startsWith('-')) {
3872
4028
  process.stderr.write(
3873
4029
  `aontu: unknown agentsmd option ${arg} (try --help)\n`)
@@ -3894,7 +4050,7 @@ function runAgentsMd(argv: string[]): number {
3894
4050
  }
3895
4051
 
3896
4052
  const report = agentsMd(src, {
3897
- name: files[0], path: files[0],
4053
+ depth, name: files[0], path: files[0],
3898
4054
  ...verbOpts(trust, entryRootOf(files[0])),
3899
4055
  })
3900
4056
  if (!report.ok) {
@@ -3933,35 +4089,23 @@ function runAgentsMd(argv: string[]): number {
3933
4089
  }
3934
4090
 
3935
4091
 
3936
- // Exit without truncating output.
3937
- //
3938
- // process.exit() terminates immediately, discarding anything still
3939
- // queued on stdout. A write to a PIPE is asynchronous once it exceeds
3940
- // the pipe buffer, so `write(big); exit(0)` silently truncated output at
3941
- // 65536 bytes — while a write to a TTY or a file, being synchronous,
3942
- // looked fine. Setting exitCode instead lets the process end naturally,
3943
- // after the queue drains.
3944
- //
3945
- // This predates the exact leaves but they make it trivially reachable
3946
- // (one long biginteger canon exceeds the buffer), and it lands squarely
3947
- // on the parity-probe discipline in AGENTS.md, which derives expected
3948
- // spec values by piping BOTH CLIs and comparing. A truncated pipe there
3949
- // reads as a port divergence.
3950
- // ---------------------------------------------------------------------
3951
- // The source formatter (docs/design/FMT.0.md): one agreed form, in the
3952
- // tradition of gofmt. The verb prints, lists, checks, diffs or rewrites;
3953
- // the form itself is the library's (ts/src/format.ts), and the two
3954
- // ports agree on it row by row in test/spec/fmt.tsv.
3955
-
3956
4092
  const FMT_HELP =
3957
- 'aontu fmt [-w|-l|--check|-d|--lint] [--marker <token>] <file>... (try --help)'
4093
+ 'aontu fmt [-w|-l|--check|-d|--lint] [--marker <token>] ' +
4094
+ '[--profile <file>] <file>... (try --help)'
3958
4095
 
3959
4096
  type FmtFlags = {
3960
4097
  write: boolean, list: boolean, check: boolean, diff: boolean, lint: boolean, strict: boolean,
3961
4098
  }
3962
4099
 
3963
4100
  function runFmt(argv: string[]): number | Promise<number> {
4101
+ const trusted = takeTrust(argv)
4102
+ if (null == trusted) {
4103
+ return 2
4104
+ }
4105
+ argv = trusted.argv
4106
+ const trust = trusted.trust
3964
4107
  const files: string[] = []
4108
+ const profileFiles: string[] = []
3965
4109
  let marker: string | undefined = undefined
3966
4110
  const flags: FmtFlags = {
3967
4111
  write: false, list: false, check: false, diff: false, lint: false, strict: false,
@@ -4002,6 +4146,14 @@ function runFmt(argv: string[]): number | Promise<number> {
4002
4146
  return 2
4003
4147
  }
4004
4148
  }
4149
+ else if ('--profile' === arg) {
4150
+ const pf = argv[++i]
4151
+ if (null == pf) {
4152
+ process.stderr.write('aontu: --profile needs a file\n')
4153
+ return 2
4154
+ }
4155
+ profileFiles.push(pf)
4156
+ }
4005
4157
  else if (arg.startsWith('-')) {
4006
4158
  process.stderr.write(`aontu: unknown fmt option ${arg} (try --help)\n`)
4007
4159
  return 2
@@ -4027,6 +4179,11 @@ function runFmt(argv: string[]): number | Promise<number> {
4027
4179
  })
4028
4180
  }
4029
4181
 
4182
+ const declared = loadProfiles(profileFiles, trust)
4183
+ if ('number' === typeof declared) {
4184
+ return declared
4185
+ }
4186
+
4030
4187
  // Several files onto standard output would be one stream nobody can
4031
4188
  // split again (the note's X-6): the verb refuses unless an option
4032
4189
  // says what to do with each.
@@ -4047,13 +4204,14 @@ function runFmt(argv: string[]): number | Promise<number> {
4047
4204
  process.stderr.write(`aontu: cannot read ${err.path}: ${err.message}\n`)
4048
4205
  return 2
4049
4206
  }
4050
- const mark = fmtMarker(file, src, marker)
4207
+ const mark = fmtMarker(file, src,
4208
+ marker ?? markerFromProfiles(declared, file))
4051
4209
  if (false === mark) {
4052
4210
  process.stderr.write(
4053
4211
  `aontu: ${file} is not aontu source (.aon, .aontu) and carries no ` +
4054
4212
  `${markerFor(file)} marker line, so there is no aontu in it to ` +
4055
- 'format; --marker names the marker for a language the table does ' +
4056
- 'not know\n')
4213
+ 'format; --marker names the marker for a language the table ' +
4214
+ 'does not know, and --profile reads one that declares it\n')
4057
4215
  return 2
4058
4216
  }
4059
4217
  worst = Math.max(worst, fmtOne(file, src, flags, mark))
@@ -4061,20 +4219,6 @@ function runFmt(argv: string[]): number | Promise<number> {
4061
4219
  return worst
4062
4220
  }
4063
4221
 
4064
- // WHAT A FILE IS, BY ITS EXTENSION (ADR-012's rule, and the one
4065
- // `render` reads an entry by): `.aon` and `.aontu` are aontu source,
4066
- // and anything else is a GENERATOR written in the target's own syntax
4067
- // (docs/design/TEMPLATE.0.md), whose marker lines carry the document
4068
- // this formats and whose other lines are output. `undefined` is aontu,
4069
- // a string is the generator's marker, and `false` is neither.
4070
- //
4071
- // A FILE WITH NO MARKER LINE IN IT IS NEITHER, and that is what keeps
4072
- // FMT.0.md §9's boundary where it stood: a `.json`, `.yaml` or `.toml`
4073
- // include is another language's file, and reading one as a generator
4074
- // would answer it back unchanged having understood none of it. The
4075
- // marker is the evidence that a file was written to carry aontu at
4076
- // all. `--marker` says so outright, and then the file is a generator
4077
- // whatever it is called.
4078
4222
  function fmtMarker(
4079
4223
  file: string, src: string, marker: string | undefined): string | undefined | false {
4080
4224
  if (undefined !== marker) {
@@ -4136,26 +4280,13 @@ function fmtOne(
4136
4280
  }
4137
4281
 
4138
4282
 
4139
- // ---------------------------------------------------------------------
4140
- // The servers as verbs: `aontu lsp` runs the language server and
4141
- // `aontu mcp` the MCP server over the CLI's own streams, so the one
4142
- // command on PATH is the editor's and the agent's server too, and a
4143
- // version manager (docs/design/ENV.0.md) has one thing to resolve. The
4144
- // standalone bins (aontu-lsp, aontu-mcp) run the same functions. Each
4145
- // server owns its exit; the CLI only dispatches. The pair is a
4146
- // parameter of main so that a test can see the dispatch without a
4147
- // server taking the test's own stdin.
4148
-
4149
4283
  type Servers = {
4150
4284
  lsp: () => void
4151
4285
  mcp: (argv: string[]) => void
4152
4286
  }
4153
4287
 
4154
- // The real pair takes the process's own stdin and stdout, which no
4155
- // in-process test can lend it; the executable-entry tests in
4156
- // cli.test.ts run each through a child process instead, so these two
4157
- // lines are excluded from the in-process count, as the stdio wiring
4158
- // of lsp-server.ts is.
4288
+ // Excluded: the real pair takes the process stdio, so ts/test/cli.test.ts
4289
+ // drives each server through a child process instead.
4159
4290
  /* node:coverage ignore next 4 */
4160
4291
  const SERVERS: Servers = {
4161
4292
  lsp: () => void lspMain(),
@@ -4202,23 +4333,353 @@ function parseTrustArg(value: string): TrustArg | undefined {
4202
4333
  }
4203
4334
 
4204
4335
 
4336
+ const HELP_VERB_HELP = 'aontu help [topic] (try `aontu help` for the topics)'
4337
+ const EXPLAIN_HELP = 'aontu explain <code> (try `aontu explain --list`)'
4338
+
4339
+
4340
+ function helpIndexText(index: HelpTopic[]): string {
4341
+ const width = index.reduce((w, t) => Math.max(w, t.topic.length), 0)
4342
+ return 'aontu help <topic> — the language, offline.\n\n' +
4343
+ index.map((t) =>
4344
+ ' ' + t.topic.padEnd(width) + ' ' + t.summary).join('\n') +
4345
+ '\n\n' +
4346
+ '`aontu --help` documents the verbs, their flags and their exit\n' +
4347
+ 'codes. `aontu explain <code>` explains one error code.\n' +
4348
+ 'Start at `aontu help tasks` if you know the job but not the verb.'
4349
+ }
4350
+
4351
+
4352
+ function runHelp(argv: string[]): number {
4353
+ let format: SubsumeFormat = 'text'
4354
+ const topics: string[] = []
4355
+
4356
+ for (let i = 0; i < argv.length; i++) {
4357
+ const arg = argv[i]
4358
+ if ('-h' === arg || '--help' === arg) {
4359
+ process.stdout.write(HELP)
4360
+ return 0
4361
+ }
4362
+ else if ('--format' === arg) {
4363
+ const f = argv[++i]
4364
+ if ('text' !== f && 'json' !== f) {
4365
+ process.stderr.write('aontu: --format needs text or json\n')
4366
+ return 2
4367
+ }
4368
+ format = f
4369
+ }
4370
+ else if (arg.startsWith('-')) {
4371
+ process.stderr.write(`aontu: unknown help option ${arg} (try --help)\n`)
4372
+ return 2
4373
+ }
4374
+ else {
4375
+ topics.push(arg)
4376
+ }
4377
+ }
4378
+
4379
+ if (1 < topics.length) {
4380
+ process.stderr.write(`aontu: help takes one topic\n${HELP_VERB_HELP}\n`)
4381
+ return 2
4382
+ }
4383
+
4384
+ if (0 === topics.length) {
4385
+ process.stdout.write(('json' === format
4386
+ ? exactJSON({
4387
+ aontu: { version: version(), verb: 'help' },
4388
+ topics: HELPDOC.map(
4389
+ (t) => ({ topic: t.topic, summary: t.summary, source: t.source })),
4390
+ }, 2)
4391
+ : helpIndexText(HELPDOC)) + '\n')
4392
+ return 0
4393
+ }
4394
+
4395
+ const found = HELPDOC.find((t) => topics[0] === t.topic)
4396
+ if (null != found) {
4397
+ if ('json' === format) {
4398
+ process.stdout.write(exactJSON({
4399
+ aontu: { version: version(), verb: 'help' },
4400
+ topic: found.topic,
4401
+ summary: found.summary,
4402
+ source: found.source,
4403
+ text: found.text,
4404
+ }, 2) + '\n')
4405
+ return 0
4406
+ }
4407
+ process.stdout.write(found.text)
4408
+ return 0
4409
+ }
4410
+
4411
+ // AN UNKNOWN TOPIC IS A USAGE ERROR AND NAMES THE ALTERNATIVES,
4412
+ // because the caller who typed it has no other way to find out what
4413
+ // exists -- that is the whole condition this verb was added for.
4414
+ process.stderr.write(
4415
+ `aontu: no help topic \`${topics[0]}\`\n` +
4416
+ `aontu: topics are ${HELPDOC.map((t) => t.topic).join(', ')}\n`)
4417
+ return 2
4418
+ }
4419
+
4420
+
4421
+ // The dynamic prefixes a generated code extends (`func:upper`,
4422
+ // `op[+]`). Mirrors CODE_PREFIXES in ts/src/hints.ts, which is not
4423
+ // exported; a code that extends one is registered through its prefix
4424
+ // and carries that prefix's hint.
4425
+ const EXPLAIN_PREFIXES = ['func:', 'op:', 'op[', 'var[', 'ref[']
4426
+
4427
+
4428
+ function explainCode(
4429
+ code: string): { cls: string, hint: string, registered: boolean } {
4430
+ const cls = codeClass(code)
4431
+ let hint = hints[code] ?? ''
4432
+ let registered = null != codeClasses[code]
4433
+ if (!registered) {
4434
+ for (const prefix of EXPLAIN_PREFIXES) {
4435
+ if (code.startsWith(prefix)) {
4436
+ // No guard on `hint` here: every hint key is also a registry
4437
+ // key (the spec suite asserts codeClasses set-equal with
4438
+ // test/spec/errcodes.tsv, and hints is a subset of it), so a
4439
+ // code that reaches this loop is unregistered and therefore
4440
+ // has no hint of its own.
4441
+ registered = true
4442
+ hint = hints[prefix] ?? ''
4443
+ break
4444
+ }
4445
+ }
4446
+ }
4447
+ return { cls, hint, registered }
4448
+ }
4449
+
4450
+
4451
+ // Every code in the shared registry, sorted by code point so both
4452
+ // ports list them in the same order.
4453
+ function explainCodes(): string[] {
4454
+ return Object.keys(codeClasses).sort(cmpCodePoint)
4455
+ }
4456
+
4457
+
4458
+ function explainListText(format: SubsumeFormat): string {
4459
+ const codes = explainCodes()
4460
+ if ('json' === format) {
4461
+ return exactJSON({
4462
+ aontu: { version: version(), verb: 'explain' },
4463
+ codes: codes.map((code) => ({
4464
+ code,
4465
+ class: codeClass(code),
4466
+ // Whether this port carries explanation text for the code. The
4467
+ // registry is in parity; the hint tables are not, so a consumer
4468
+ // that wants only explained codes can filter rather than guess.
4469
+ explained: '' !== explainCode(code).hint,
4470
+ })),
4471
+ }, 2)
4472
+ }
4473
+ const width = codes.reduce((w, c) => Math.max(w, c.length), 0)
4474
+ return codes.map((c) =>
4475
+ c.padEnd(width) + ' ' + codeClass(c) +
4476
+ ('' === explainCode(c).hint ? ' (no text)' : '')).join('\n')
4477
+ }
4478
+
4479
+
4480
+ function runExplain(argv: string[]): number {
4481
+ let format: SubsumeFormat = 'text'
4482
+ let list = false
4483
+ const codes: string[] = []
4484
+
4485
+ for (let i = 0; i < argv.length; i++) {
4486
+ const arg = argv[i]
4487
+ if ('-h' === arg || '--help' === arg) {
4488
+ process.stdout.write(HELP)
4489
+ return 0
4490
+ }
4491
+ else if ('--list' === arg) {
4492
+ list = true
4493
+ }
4494
+ else if ('--format' === arg) {
4495
+ const f = argv[++i]
4496
+ if ('text' !== f && 'json' !== f) {
4497
+ process.stderr.write('aontu: --format needs text or json\n')
4498
+ return 2
4499
+ }
4500
+ format = f
4501
+ }
4502
+ else if (arg.startsWith('-')) {
4503
+ process.stderr.write(
4504
+ `aontu: unknown explain option ${arg} (try --help)\n`)
4505
+ return 2
4506
+ }
4507
+ else {
4508
+ codes.push(arg)
4509
+ }
4510
+ }
4511
+
4512
+ if (list) {
4513
+ if (0 < codes.length) {
4514
+ process.stderr.write(`aontu: --list takes no code\n${EXPLAIN_HELP}\n`)
4515
+ return 2
4516
+ }
4517
+ process.stdout.write(explainListText(format) + '\n')
4518
+ return 0
4519
+ }
4520
+
4521
+ if (1 !== codes.length) {
4522
+ process.stderr.write(`aontu: explain needs one code\n${EXPLAIN_HELP}\n`)
4523
+ return 2
4524
+ }
4525
+
4526
+ const code = codes[0]
4527
+ const { cls, hint, registered } = explainCode(code)
4528
+ if (!registered) {
4529
+ // AN UNKNOWN CODE IS A USAGE ERROR AND NAMES NEAR MATCHES. A
4530
+ // caller reading a code out of a report has almost certainly typed
4531
+ // it correctly, so the likely cause is a code from another tool or
4532
+ // a truncated one, and the near matches say which.
4533
+ process.stderr.write(`aontu: no such error code \`${code}\`\n`)
4534
+ const near = nearestVerb(code, explainCodes())
4535
+ if ('' !== near) {
4536
+ process.stderr.write(`aontu: did you mean \`${near}\`?\n`)
4537
+ }
4538
+ process.stderr.write(
4539
+ 'aontu: `aontu explain --list` lists every registered code\n')
4540
+ return 2
4541
+ }
4542
+
4543
+ if ('json' === format) {
4544
+ process.stdout.write(exactJSON({
4545
+ aontu: { version: version(), verb: 'explain' },
4546
+ code,
4547
+ class: cls,
4548
+ hint,
4549
+ }, 2) + '\n')
4550
+ return 0
4551
+ }
4552
+ // A REGISTERED CODE WITH NO HINT SAYS SO rather than printing an
4553
+ // empty block, which would read as an explanation that happened to
4554
+ // be blank.
4555
+ const body = '' === hint
4556
+ ? '(no explanation text is registered for this code)'
4557
+ : hint
4558
+ process.stdout.write(`code: ${code}\nclass: ${cls}\n\n${body}\n`)
4559
+ return 0
4560
+ }
4561
+
4562
+
4563
+ const INIT_HELP = 'aontu init [dir] (try --help)'
4564
+
4565
+
4566
+ function runInit(argv: string[]): number {
4567
+ const dirs: string[] = []
4568
+
4569
+ for (const arg of argv) {
4570
+ if ('-h' === arg || '--help' === arg) {
4571
+ process.stdout.write(HELP)
4572
+ return 0
4573
+ }
4574
+ if (arg.startsWith('-')) {
4575
+ process.stderr.write(`aontu: unknown init option ${arg} (try --help)\n`)
4576
+ return 2
4577
+ }
4578
+ dirs.push(arg)
4579
+ }
4580
+
4581
+ if (1 < dirs.length) {
4582
+ process.stderr.write(`aontu: init takes one directory\n${INIT_HELP}\n`)
4583
+ return 2
4584
+ }
4585
+ const dir = dirs[0] ?? '.'
4586
+
4587
+ const standing = INITDOC.filter((f) => existsSync(join(dir, f.name)))
4588
+ if (0 < standing.length) {
4589
+ process.stderr.write(
4590
+ `aontu: ${dir} already holds ${standing.map((f) => f.name).join(', ')}\n` +
4591
+ 'aontu: init never overwrites; move them aside or name an' +
4592
+ ' empty directory\n')
4593
+ return 2
4594
+ }
4595
+
4596
+ try {
4597
+ mkdirSync(dir, { recursive: true })
4598
+ for (const f of INITDOC) {
4599
+ writeFileSync(join(dir, f.name), f.text, { mode: f.mode })
4600
+ }
4601
+ }
4602
+ catch (err: any) {
4603
+ process.stderr.write(`aontu: cannot write in ${dir}: ${err.message}\n`)
4604
+ return 2
4605
+ }
4606
+
4607
+ process.stdout.write(
4608
+ INITDOC.map((f) => join(dir, f.name)).join('\n') + '\n' +
4609
+ '\nA model, an instance of it, and the four questions to ask.\n' +
4610
+ 'Run the checks: sh ' + join(dir, 'check.sh') + '\n' +
4611
+ 'Learn the language: aontu help language\n')
4612
+ return 0
4613
+ }
4614
+
4615
+
4616
+ const KNOWN_VERBS = [
4617
+ 'agentsmd', 'allow', 'breaking', 'explain', 'fmt', 'get', 'hash',
4618
+ 'help', 'init', 'jsonschema', 'lsp', 'mcp', 'mod', 'reaches',
4619
+ 'relations', 'render', 'set', 'subsume', 'template', 'trim', 'vet',
4620
+ 'view', 'why',
4621
+ ]
4622
+
4623
+
4624
+ // looksLikeVerb reports whether an unreadable argument was meant as a
4625
+ // verb rather than as a path. A bare word has no separator and no
4626
+ // extension; `./help`, `help.aon`, `/tmp/help` and `sub/dir` are paths
4627
+ // and keep the file diagnosis. Mirrors go/cmd/aontu/main.go.
4628
+ function looksLikeVerb(arg: string): boolean {
4629
+ return '' !== arg &&
4630
+ !/[/\\.]/.test(arg) &&
4631
+ !arg.startsWith('-')
4632
+ }
4633
+
4634
+
4635
+ function nearestVerb(word: string, verbs: string[]): string {
4636
+ let best = ''
4637
+ let bestDist = Infinity
4638
+ const limit = Math.min(3, 1 + Math.floor(word.length / 4))
4639
+ for (const v of [...verbs].sort(cmpCodePoint)) {
4640
+ const d = editDistance(word.toLowerCase(), v)
4641
+ if (d < bestDist) {
4642
+ best = v
4643
+ bestDist = d
4644
+ }
4645
+ }
4646
+ return bestDist > limit ? '' : best
4647
+ }
4648
+
4649
+
4650
+ function editDistance(a: string, b: string): number {
4651
+ const ar = [...a]
4652
+ const br = [...b]
4653
+ let prev2 = new Array(br.length + 1).fill(0)
4654
+ let prev = new Array(br.length + 1).fill(0).map((_, j) => j)
4655
+ let cur = new Array(br.length + 1).fill(0)
4656
+ for (let i = 1; i <= ar.length; i++) {
4657
+ cur[0] = i
4658
+ for (let j = 1; j <= br.length; j++) {
4659
+ const cost = ar[i - 1] === br[j - 1] ? 0 : 1
4660
+ let m = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost)
4661
+ if (1 < i && 1 < j &&
4662
+ ar[i - 1] === br[j - 2] && ar[i - 2] === br[j - 1] &&
4663
+ prev2[j - 2] + 1 < m) {
4664
+ m = prev2[j - 2] + 1
4665
+ }
4666
+ cur[j] = m
4667
+ }
4668
+ prev2 = [...prev]
4669
+ prev = [...cur]
4670
+ }
4671
+ return prev[br.length]
4672
+ }
4673
+
4674
+
4205
4675
  function main(argv: string[], servers: Servers = SERVERS): void {
4206
- // COLOUR OFF WHEN THE DESTINATION IS NOT A TERMINAL. Error frames
4207
- // hardcoded their ANSI escapes, so a piped report and a `--jsonl`
4208
- // answer carried terminal control codes into whatever read them (the
4209
- // review's finding F). `NO_COLOR` is honoured by the library itself;
4210
- // only the CLI can see whether its stderr is a terminal, so only the
4211
- // CLI can make this call. `undefined` means "leave it to NO_COLOR".
4212
4676
  setColor(true === process.stderr.isTTY ? undefined : false)
4213
4677
 
4214
4678
  let mode: Mode = 'json'
4215
- // A LIST, though the bare command evaluates exactly one document.
4216
- // It used to be one variable and the last argument won, which made a
4217
- // MISTYPED VERB a silent success: `aontu vet2 schema.aon good.json`
4218
- // printed good.json and exited 0, because `vet2` matched no
4219
- // subcommand, fell through to this loop as a file name, and was
4220
- // overwritten twice. In a tool loop that reads as a passing
4221
- // validation. Counting them is what lets the refusal below happen.
4679
+ // THE REPORT FORM (G11 phase 7), default text: every existing caller
4680
+ // reads exactly what it always read, and a caller that asks for json
4681
+ // gets the failure in the finding shape every other verb reports.
4682
+ let format: EvalFormat = 'text'
4222
4683
  const files: string[] = []
4223
4684
  let trust: TrustArg = { kind: 'system-warn', textExt: [] }
4224
4685
  let textExt: string[] = []
@@ -4228,15 +4689,6 @@ function main(argv: string[], servers: Servers = SERVERS): void {
4228
4689
  // mode the REPL already has.
4229
4690
  let jsonl = false
4230
4691
 
4231
- // Subcommand dispatch, and deliberately only for a FIRST argument:
4232
- // `aontu vet` is the verb, while `aontu somefile vet` keeps meaning
4233
- // what it always did. A file named `vet` is still reachable as
4234
- // `aontu ./vet`.
4235
- //
4236
- // Promise.resolve either way: a non-watch run returns its exit class
4237
- // synchronously (and has already written its report), while `--watch`
4238
- // resolves only when the watch ends — so one await-shaped line serves
4239
- // both without a branch to keep covered.
4240
4692
  if ('vet' === argv[2]) {
4241
4693
  return void Promise.resolve(runVet(argv.slice(3))).then(finish)
4242
4694
  }
@@ -4264,6 +4716,10 @@ function main(argv: string[], servers: Servers = SERVERS): void {
4264
4716
  return finish(runSet(argv.slice(3)))
4265
4717
  }
4266
4718
 
4719
+ if ('allow' === argv[2]) {
4720
+ return finish(runAllow(argv.slice(3)))
4721
+ }
4722
+
4267
4723
  if ('why' === argv[2]) {
4268
4724
  return finish(runWhy(argv.slice(3)))
4269
4725
  }
@@ -4276,6 +4732,20 @@ function main(argv: string[], servers: Servers = SERVERS): void {
4276
4732
  return finish(runHash(argv.slice(3)))
4277
4733
  }
4278
4734
 
4735
+ // G11 phases 1 and 3. Dispatched with the rest, so `aontu ./help`
4736
+ // still reads a file named help exactly as `aontu ./vet` does.
4737
+ if ('help' === argv[2]) {
4738
+ return finish(runHelp(argv.slice(3)))
4739
+ }
4740
+
4741
+ if ('explain' === argv[2]) {
4742
+ return finish(runExplain(argv.slice(3)))
4743
+ }
4744
+
4745
+ if ('init' === argv[2]) {
4746
+ return finish(runInit(argv.slice(3)))
4747
+ }
4748
+
4279
4749
  if ('mod' === argv[2]) {
4280
4750
  return finish(runMod(argv.slice(3)))
4281
4751
  }
@@ -4331,6 +4801,14 @@ function main(argv: string[], servers: Servers = SERVERS): void {
4331
4801
  }
4332
4802
  trust = parsed
4333
4803
  }
4804
+ else if ('--format' === arg) {
4805
+ const f = args[++i]
4806
+ if ('text' !== f && 'json' !== f) {
4807
+ process.stderr.write('aontu: --format needs text or json\n')
4808
+ return finish(2)
4809
+ }
4810
+ format = f
4811
+ }
4334
4812
  else if ('--jsonl' === arg) {
4335
4813
  jsonl = true
4336
4814
  // A JSONL answer is machine-read by definition, even when the
@@ -4367,14 +4845,6 @@ function main(argv: string[], servers: Servers = SERVERS): void {
4367
4845
  }
4368
4846
  }
4369
4847
 
4370
- // ONE DOCUMENT. The bare form has always been `aontu [options]
4371
- // [file]`, singular, and anything past the first was silently
4372
- // discarded rather than refused -- so every way of getting the verb
4373
- // wrong (a typo, a verb this port does not have, a verb spelled for
4374
- // another tool) ended in a plausible answer about the wrong file.
4375
- // Exit 2, the usage class, and the message names the cause rather
4376
- // than the symptom: nothing here can tell a mistyped verb from a
4377
- // second file, but the reader can.
4378
4848
  if (1 < files.length) {
4379
4849
  process.stderr.write(
4380
4850
  `aontu: the bare command evaluates one document, and ${files.length}` +
@@ -4383,14 +4853,11 @@ function main(argv: string[], servers: Servers = SERVERS): void {
4383
4853
  return finish(2)
4384
4854
  }
4385
4855
 
4386
- // The extensions ride with the capability from here on, so the three
4387
- // entry shapes below (file, REPL, stdin) each get them by threading
4388
- // the one value they already thread.
4389
4856
  trust = { ...trust, textExt }
4390
4857
 
4391
4858
  const file = files[0]
4392
4859
  if (null != file) {
4393
- finish(runFile(file, mode, trust))
4860
+ finish(runFile(file, mode, format, trust))
4394
4861
  }
4395
4862
  // `--jsonl` overrides the TTY gate: the mode exists to be DRIVEN by
4396
4863
  // a harness over a pipe, so gating it on an interactive terminal
@@ -4400,9 +4867,9 @@ function main(argv: string[], servers: Servers = SERVERS): void {
4400
4867
  runRepl(mode, jsonl, trust)
4401
4868
  }
4402
4869
  else {
4403
- runStdin(mode, trust).then((code) => finish(code))
4870
+ runStdin(mode, format, trust).then((code) => finish(code))
4404
4871
  }
4405
- } /* node:coverage ignore next 18 */
4872
+ } /* node:coverage ignore next 20 */
4406
4873
 
4407
4874
 
4408
4875
  // No require.main guard here: bin/aontu.js is the executable entry and
@@ -4417,7 +4884,9 @@ export {
4417
4884
  runRender,
4418
4885
  runTemplate,
4419
4886
  runMod,
4420
- runHash, runGet,
4421
- runWhy, renderWhyText, runSet, runAgentsMd, runFmt,
4887
+ runHash, runGet, runHelp, runExplain, runInit, nearestVerb,
4888
+ looksLikeVerb,
4889
+ KNOWN_VERBS,
4890
+ runWhy, renderWhyText, runSet, runAllow, runAgentsMd, runFmt,
4422
4891
  watchChange, watchSignature, vetWaiter, deprecatedAt,
4423
4892
  }