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