aontu 0.56.0 → 0.58.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 (138) hide show
  1. package/README.md +2 -2
  2. package/dist/agentsmd.js +1 -1
  3. package/dist/alias.d.ts +3 -0
  4. package/dist/alias.js +59 -0
  5. package/dist/alias.js.map +1 -0
  6. package/dist/aontu.d.ts +5 -2
  7. package/dist/aontu.js +11 -3
  8. package/dist/aontu.js.map +1 -1
  9. package/dist/cli.d.ts +8 -2
  10. package/dist/cli.js +564 -21
  11. package/dist/cli.js.map +1 -1
  12. package/dist/ctx.d.ts +2 -0
  13. package/dist/ctx.js +1 -0
  14. package/dist/ctx.js.map +1 -1
  15. package/dist/escape.d.ts +5 -0
  16. package/dist/escape.js +455 -0
  17. package/dist/escape.js.map +1 -0
  18. package/dist/format.d.ts +9 -0
  19. package/dist/format.js +550 -55
  20. package/dist/format.js.map +1 -1
  21. package/dist/hints.js +74 -9
  22. package/dist/hints.js.map +1 -1
  23. package/dist/lang.js +374 -58
  24. package/dist/lang.js.map +1 -1
  25. package/dist/lower.d.ts +20 -0
  26. package/dist/lower.js +575 -0
  27. package/dist/lower.js.map +1 -0
  28. package/dist/lsp.d.ts +1 -1
  29. package/dist/lsp.js +4 -4
  30. package/dist/lsp.js.map +1 -1
  31. package/dist/mcp-server.js +2 -2
  32. package/dist/mcp-server.js.map +1 -1
  33. package/dist/mcp.d.ts +1 -0
  34. package/dist/mcp.js +40 -3
  35. package/dist/mcp.js.map +1 -1
  36. package/dist/mod-tool.js +8 -7
  37. package/dist/mod-tool.js.map +1 -1
  38. package/dist/mod.js +6 -6
  39. package/dist/mod.js.map +1 -1
  40. package/dist/render.d.ts +53 -0
  41. package/dist/render.js +542 -0
  42. package/dist/render.js.map +1 -0
  43. package/dist/sigdecl.js +1 -1
  44. package/dist/sigdecl.js.map +1 -1
  45. package/dist/std.d.ts +2 -0
  46. package/dist/std.js +498 -2
  47. package/dist/std.js.map +1 -1
  48. package/dist/template.d.ts +5 -0
  49. package/dist/template.js +257 -0
  50. package/dist/template.js.map +1 -0
  51. package/dist/tsconfig.tsbuildinfo +1 -1
  52. package/dist/unify.js +43 -0
  53. package/dist/unify.js.map +1 -1
  54. package/dist/val/AggFuncVal.d.ts +1 -1
  55. package/dist/val/AggFuncVal.js +10 -21
  56. package/dist/val/AggFuncVal.js.map +1 -1
  57. package/dist/val/BagVal.js +1 -1
  58. package/dist/val/BagVal.js.map +1 -1
  59. package/dist/val/ConstraintVal.js +1 -1
  60. package/dist/val/EachFuncVal.d.ts +1 -1
  61. package/dist/val/EachFuncVal.js +9 -15
  62. package/dist/val/EachFuncVal.js.map +1 -1
  63. package/dist/val/EmitFuncVal.d.ts +42 -0
  64. package/dist/val/EmitFuncVal.js +531 -0
  65. package/dist/val/EmitFuncVal.js.map +1 -0
  66. package/dist/val/FilterFuncVal.js +9 -6
  67. package/dist/val/FilterFuncVal.js.map +1 -1
  68. package/dist/val/FormFuncVal.d.ts +14 -0
  69. package/dist/val/FormFuncVal.js +55 -0
  70. package/dist/val/FormFuncVal.js.map +1 -0
  71. package/dist/val/FuncBaseVal.d.ts +1 -0
  72. package/dist/val/FuncBaseVal.js +16 -0
  73. package/dist/val/FuncBaseVal.js.map +1 -1
  74. package/dist/val/MapVal.d.ts +2 -1
  75. package/dist/val/MapVal.js +2 -1
  76. package/dist/val/MapVal.js.map +1 -1
  77. package/dist/val/PackFuncVal.d.ts +1 -1
  78. package/dist/val/PackFuncVal.js +23 -20
  79. package/dist/val/PackFuncVal.js.map +1 -1
  80. package/dist/val/PlaceVal.d.ts +3 -1
  81. package/dist/val/PlaceVal.js +9 -6
  82. package/dist/val/PlaceVal.js.map +1 -1
  83. package/dist/val/RefVal.d.ts +3 -0
  84. package/dist/val/RefVal.js +156 -17
  85. package/dist/val/RefVal.js.map +1 -1
  86. package/dist/val/StrFuncVal.d.ts +32 -0
  87. package/dist/val/StrFuncVal.js +292 -0
  88. package/dist/val/StrFuncVal.js.map +1 -0
  89. package/dist/val/Val.d.ts +7 -1
  90. package/dist/val/Val.js +12 -1
  91. package/dist/val/Val.js.map +1 -1
  92. package/dist/val/members.d.ts +9 -0
  93. package/dist/val/members.js +52 -0
  94. package/dist/val/members.js.map +1 -0
  95. package/grammar/aontu.abnf +4 -3
  96. package/grammar/aontu.gbnf +4 -3
  97. package/grammar/aontu.lark +4 -3
  98. package/grammar/aontu.tmLanguage.json +1 -1
  99. package/package.json +1 -1
  100. package/skill/SKILL.md +4 -4
  101. package/skill/error-codes.md +1 -1
  102. package/skill/examples.md +1 -1
  103. package/skill/grammar-card.md +1 -1
  104. package/src/agentsmd.ts +1 -1
  105. package/src/alias.ts +112 -0
  106. package/src/aontu.ts +20 -2
  107. package/src/cli.ts +630 -23
  108. package/src/ctx.ts +13 -0
  109. package/src/escape.ts +371 -0
  110. package/src/format.ts +648 -56
  111. package/src/hints.ts +94 -9
  112. package/src/lang.ts +430 -63
  113. package/src/lower.ts +636 -0
  114. package/src/lsp.ts +4 -4
  115. package/src/mcp-server.ts +3 -2
  116. package/src/mcp.ts +43 -4
  117. package/src/mod-tool.ts +9 -8
  118. package/src/mod.ts +6 -6
  119. package/src/render.ts +727 -0
  120. package/src/sigdecl.ts +1 -1
  121. package/src/std.ts +506 -1
  122. package/src/template.ts +291 -0
  123. package/src/unify.ts +47 -0
  124. package/src/val/AggFuncVal.ts +10 -21
  125. package/src/val/BagVal.ts +1 -1
  126. package/src/val/ConstraintVal.ts +1 -1
  127. package/src/val/EachFuncVal.ts +9 -19
  128. package/src/val/EmitFuncVal.ts +738 -0
  129. package/src/val/FilterFuncVal.ts +12 -7
  130. package/src/val/FormFuncVal.ts +119 -0
  131. package/src/val/FuncBaseVal.ts +18 -0
  132. package/src/val/MapVal.ts +3 -2
  133. package/src/val/PackFuncVal.ts +24 -22
  134. package/src/val/PlaceVal.ts +9 -6
  135. package/src/val/RefVal.ts +167 -18
  136. package/src/val/StrFuncVal.ts +334 -0
  137. package/src/val/Val.ts +42 -1
  138. package/src/val/members.ts +86 -0
package/src/cli.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  import { evalFailure } from './query'
13
13
  // __importStar downlevel helper, whose branches no supported Node takes.
14
14
  import {
15
- mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync,
15
+ existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync,
16
16
  } from 'node:fs'
17
17
  import { basename, dirname, join, resolve } from 'node:path'
18
18
  import { tmpdir } from 'node:os'
@@ -23,8 +23,15 @@ import {
23
23
  exactJSON, vet, subsume, trimCheck, relationCheck,
24
24
  hcanon, canonHash,
25
25
  get, why, patch, agentsMd,
26
+ render,
27
+ renderProfile,
26
28
  } from './aontu'
29
+ import type { RenderCoverage, RenderReport } from './render'
30
+ import { desugarTemplate, resugarTemplate, markerFor } from './template'
31
+ import { outsideRoot } from './mcp'
27
32
  import { sarifReport } from './report-sarif'
33
+ import { main as lspMain } from './lsp-server'
34
+ import { main as mcpMain } from './mcp-server'
28
35
  import { jsonSchema } from './jsonschema'
29
36
  import { modTidy, modVerify, modVendor, modManifest } from './mod-tool'
30
37
  import type {
@@ -66,15 +73,21 @@ const HELP = `Usage: aontu [options] [file]
66
73
  aontu view <kind> [options] <file>...
67
74
  aontu view --views <path> [--check] [options] <file>
68
75
  aontu jsonschema [--at <path>] [--strict] [options] <file>
76
+ aontu render [--at <path>] [--profile <file>]... [--unit <path>]
77
+ [--stdout | --out <dir> | --check <dir> | --coverage]
78
+ [--coverage-at <path>] [--strict] <file>
79
+ aontu template [--resugar] [--check] [--marker <token>] <file>
69
80
  aontu hash [options] <file>
70
81
  aontu mod tidy|verify|vendor|manifest [options] [dir]
71
82
  aontu get <path> [options] <file>
72
83
  aontu why <path> [options] <file>
73
84
  aontu set <path>=<value>... --entry <file> --overlay <file>
74
85
  aontu agentsmd [--write <AGENTS.md>] <file>
75
- aontu fmt [-w|-l|--check|-d] <file>...
86
+ aontu fmt [-w|-l|--check|-d|--lint] <file>...
87
+ aontu lsp
88
+ aontu mcp [--root <dir>]
76
89
 
77
- Evaluate an Aontu source file and print the result as JSON.
90
+ Evaluate an aontu source file and print the result as JSON.
78
91
  With no file on an interactive terminal, start a REPL.
79
92
  With no file and piped input, read the source from stdin.
80
93
 
@@ -106,10 +119,10 @@ Mod options:
106
119
 
107
120
  Mod subcommands:
108
121
  tidy Resolve the module closure by minimum version selection and
109
- rewrite mod-lock.aon in canonical form
110
- verify Check every locked module still means what mod-lock.aon
122
+ rewrite aontu_meta/mod-lock.aon in canonical form
123
+ verify Check every locked module still means what the lockfile
111
124
  pins, and change nothing (the CI gate; tidy rewrites)
112
- vendor Materialise the locked closure into aon_vendor/
125
+ vendor Materialise the locked closure into aontu_meta/vendor/
113
126
  manifest Print the OCI artifact a publish would push, gated on the
114
127
  breaking check against --against
115
128
 
@@ -256,6 +269,51 @@ View exit codes: 0 rendered, 1 --check mismatch or lossy under
256
269
  --strict, 2 usage or --max-rows exceeded, 4 the document does not stand
257
270
  up on its own, or a relation, root or path that names nothing.
258
271
 
272
+ Render options:
273
+ --at <path> Render the value at this path ($.a.b); the root by
274
+ default
275
+ --profile <file> A profile document, profile: {lang, ...}, vetted
276
+ against aontu:profile; repeatable, one per language
277
+ --unit <path> Render only the unit with this path
278
+ --stdout One unit's bytes and nothing else (with --unit when
279
+ the instance has several)
280
+ --out <dir> Write every unit below dir, or nothing; never deletes
281
+ --check <dir> Compare every unit with dir/<path>; drift is listed
282
+ --coverage Report what the render read and what it did not:
283
+ model paths no output consumed, and rendered
284
+ declarations no rule produced. Writes nothing
285
+ --coverage-at <p> Measure coverage under this path only, instead of
286
+ the document root
287
+ --strict Refuse the opaque escapes (a text declaration, a raw
288
+ block)
289
+ --format <f> text (default) or json, the whole report; json
290
+ carries the dispatch trace, one entry per emitted
291
+ piece
292
+
293
+ Render exit codes: 0 rendered, 1 lossy under --strict or drift under
294
+ --check, 2 usage or I/O (a refused unit path included), 4 the document
295
+ does not stand up or the instance is not aontu:code.
296
+
297
+ A render entry file whose extension is not .aon is a TEMPLATE: a
298
+ generator in the target's own syntax, whose marker lines carry aontu
299
+ and whose other lines are output. It is desugared before it is
300
+ evaluated, and --marker names the marker for a language the table does
301
+ not know.
302
+
303
+ Template options:
304
+ --resugar The file is the canonical aontu; print the template
305
+ form instead of reading one
306
+ --check Desugar and resugar, and exit 1 if the file is not
307
+ what the round trip answers
308
+ --marker <t> The marker, when the extension does not name it
309
+ (default //-, and #- --- /*- by extension)
310
+
311
+ The template verb prints the canonical aontu form of a generator
312
+ written in the target's own syntax: a marked line is aontu source, and
313
+ every other line is a line of output.
314
+
315
+ Template exit codes: 0 written, 1 --check drift, 2 usage or I/O.
316
+
259
317
  Set options:
260
318
  --entry <file> The document the change is checked against
261
319
  --overlay <file> The file the change is appended to (created if
@@ -290,12 +348,25 @@ Fmt options:
290
348
  --check Like --list, and exit 1 when any would: the CI gate
291
349
  -d, --diff Print a unified diff for each file whose form would
292
350
  change
351
+ --lint Report the style findings, key case and repeated
352
+ shapes, on standard error, and print nothing else
353
+ --strict With --lint, and exit 1 when there is a finding
293
354
 
294
355
  The fmt verb prints one document in the agreed form; with no file it
295
356
  reads standard input. Several files need one of the options above.
296
357
 
297
- Fmt exit codes: 0 formatted or clean, 1 a --check file would change,
298
- 2 usage, 4 a document does not parse.
358
+ Fmt exit codes: 0 formatted or clean, 1 a --check file would change or
359
+ a --strict finding, 2 usage, 4 a document does not parse.
360
+
361
+ The lsp verb runs the language server over standard input and output
362
+ (LSP: JSON-RPC with Content-Length framing) until the client exits;
363
+ editors launch it with no arguments. The standalone aontu-lsp binary
364
+ runs the same server.
365
+
366
+ The mcp verb runs the Model Context Protocol server over standard
367
+ input and output (newline-delimited JSON-RPC), confined below --root
368
+ when one is given. It is part of the npm build, as the standalone
369
+ aontu-mcp binary is.
299
370
 
300
371
  REPL commands:
301
372
  :help Show REPL help
@@ -712,7 +783,7 @@ function runRepl(initialMode: Mode, jsonl: boolean, trust: TrustArg): void {
712
783
 
713
784
  if (!jsonl) {
714
785
  process.stdout.write(
715
- `Aontu v${version()} REPL — :help for commands, :quit to exit\n`)
786
+ `aontu v${version()} REPL — :help for commands, :quit to exit\n`)
716
787
  }
717
788
  rl.prompt()
718
789
 
@@ -1994,6 +2065,16 @@ function runMod(argv: string[]): number {
1994
2065
  return 2
1995
2066
  }
1996
2067
 
2068
+ // THE OLD LAYOUT IS NAMED, NOT READ. The lockfile and the vendored
2069
+ // closure moved under aontu_meta/; a project that still carries them
2070
+ // at its root would otherwise look untouched by any of these verbs,
2071
+ // which is the one silence worth breaking.
2072
+ if (existsSync(join(dir, 'aon_vendor')) || existsSync(join(dir, 'mod-lock.aon'))) {
2073
+ process.stderr.write(
2074
+ 'aontu: aon_vendor/ and mod-lock.aon now live under aontu_meta/: ' +
2075
+ 'move them, or run aontu mod tidy and aontu mod vendor\n')
2076
+ }
2077
+
1997
2078
  // `--against` gates a manifest and means nothing to the other two;
1998
2079
  // accepting it there would say it had been honoured.
1999
2080
  if (null != against && 'manifest' !== sub) {
@@ -2830,6 +2911,439 @@ function runJsonSchema(argv: string[]): number {
2830
2911
  strict && 'lossy' === report.verdict ? 1 : 0
2831
2912
  }
2832
2913
 
2914
+ // ---------------------------------------------------------------------
2915
+ // THE RENDER VERB (docs/design/RENDER.0.md D8): evaluate a document,
2916
+ // vet the value at --at against aontu:code, fold code.units into bytes,
2917
+ // and put them where the flag says -- one unit on stdout, every unit
2918
+ // below --out (all or nothing), or compared against --check. Exit codes
2919
+ // mirror jsonschema's: 0 ok; 1 lossy under --strict or drift under
2920
+ // --check; 2 usage or I/O, a refused unit path included; 4 the
2921
+ // document does not stand up or the instance is not aontu:code.
2922
+
2923
+ const RENDER_HELP =
2924
+ 'aontu render [--at <path>] [--profile <file>]... [--unit <path>] ' +
2925
+ '[--stdout | --out <dir> | --check <dir> | --coverage] ' +
2926
+ '[--coverage-at <path>] [--strict] [--marker <token>] <file> (try --help)'
2927
+
2928
+ function runRender(argv: string[]): number {
2929
+ const trusted = takeTrust(argv)
2930
+ if (null == trusted) {
2931
+ return 2
2932
+ }
2933
+ argv = trusted.argv
2934
+ const trust = trusted.trust
2935
+ const files: string[] = []
2936
+ const profileFiles: string[] = []
2937
+ let format: SubsumeFormat = 'text'
2938
+ let at: string | undefined = undefined
2939
+ let unit: string | undefined = undefined
2940
+ let out: string | undefined = undefined
2941
+ let check: string | undefined = undefined
2942
+ let toStdout = false
2943
+ let strict = false
2944
+ let coverage = false
2945
+ let coverageAt: string | undefined = undefined
2946
+ let marker: string | undefined = undefined
2947
+
2948
+ for (let i = 0; i < argv.length; i++) {
2949
+ const arg = argv[i]
2950
+ if ('-h' === arg || '--help' === arg) {
2951
+ process.stdout.write(HELP)
2952
+ return 0
2953
+ }
2954
+ if ('--format' === arg) {
2955
+ const f = argv[++i]
2956
+ if ('text' !== f && 'json' !== f) {
2957
+ process.stderr.write('aontu: --format needs text or json\n')
2958
+ return 2
2959
+ }
2960
+ format = f
2961
+ }
2962
+ else if ('--at' === arg) {
2963
+ at = argv[++i]
2964
+ if (null == at) {
2965
+ process.stderr.write('aontu: --at needs a path\n')
2966
+ return 2
2967
+ }
2968
+ }
2969
+ else if ('--unit' === arg) {
2970
+ unit = argv[++i]
2971
+ if (null == unit) {
2972
+ process.stderr.write('aontu: --unit needs a unit path\n')
2973
+ return 2
2974
+ }
2975
+ }
2976
+ else if ('--profile' === arg) {
2977
+ const pf = argv[++i]
2978
+ if (null == pf) {
2979
+ process.stderr.write('aontu: --profile needs a file\n')
2980
+ return 2
2981
+ }
2982
+ profileFiles.push(pf)
2983
+ }
2984
+ else if ('--out' === arg) {
2985
+ out = argv[++i]
2986
+ if (null == out) {
2987
+ process.stderr.write('aontu: --out needs a directory\n')
2988
+ return 2
2989
+ }
2990
+ }
2991
+ else if ('--check' === arg) {
2992
+ check = argv[++i]
2993
+ if (null == check) {
2994
+ process.stderr.write('aontu: --check needs a directory\n')
2995
+ return 2
2996
+ }
2997
+ }
2998
+ else if ('--stdout' === arg) {
2999
+ toStdout = true
3000
+ }
3001
+ else if ('--coverage' === arg) {
3002
+ coverage = true
3003
+ }
3004
+ else if ('--marker' === arg) {
3005
+ marker = argv[++i]
3006
+ if (null == marker) {
3007
+ process.stderr.write('aontu: --marker needs a token\n')
3008
+ return 2
3009
+ }
3010
+ }
3011
+ else if ('--coverage-at' === arg) {
3012
+ coverageAt = argv[++i]
3013
+ if (null == coverageAt) {
3014
+ process.stderr.write('aontu: --coverage-at needs a path\n')
3015
+ return 2
3016
+ }
3017
+ }
3018
+ else if ('--strict' === arg) {
3019
+ strict = true
3020
+ }
3021
+ else if (arg.startsWith('-')) {
3022
+ process.stderr.write(
3023
+ `aontu: unknown render option ${arg} (try --help)\n`)
3024
+ return 2
3025
+ }
3026
+ else {
3027
+ files.push(arg)
3028
+ }
3029
+ }
3030
+
3031
+ if (1 !== files.length) {
3032
+ process.stderr.write(`aontu: render needs one file\n${RENDER_HELP}\n`)
3033
+ return 2
3034
+ }
3035
+ const modes = [toStdout, undefined !== out, undefined !== check, coverage]
3036
+ .filter((on) => on).length
3037
+ if (1 < modes) {
3038
+ process.stderr.write(
3039
+ 'aontu: render takes one of --stdout, --out, --check or --coverage\n')
3040
+ return 2
3041
+ }
3042
+ // A NARROWER MEASURE NEEDS SOMETHING TO NARROW. `--coverage-at`
3043
+ // without `--coverage` asks for a report the run does not compute,
3044
+ // and answering silently would be the wrong half of the request.
3045
+ if (undefined !== coverageAt && !coverage) {
3046
+ process.stderr.write('aontu: --coverage-at needs --coverage\n')
3047
+ return 2
3048
+ }
3049
+
3050
+ let src: string
3051
+ try {
3052
+ src = readFileSync(files[0], 'utf8')
3053
+ }
3054
+ catch (err: any) {
3055
+ process.stderr.write(`aontu: cannot read ${err.path}: ${err.message}\n`)
3056
+ return 2
3057
+ }
3058
+
3059
+ // THE ENTRY MAY BE A TEMPLATE (TEMPLATE.0.md; P8), and its EXTENSION
3060
+ // decides, as an include's extension decides what the include is
3061
+ // (ADR-012): a generator is a file in the target's own syntax, so it
3062
+ // carries the target's extension and never `.aon`. Desugared here
3063
+ // rather than anywhere deeper, because a template is an entry
3064
+ // spelling and not a value: an include is still aontu.
3065
+ if (!files[0].endsWith('.aon')) {
3066
+ src = desugarTemplate(src, marker ?? markerFor(files[0]))
3067
+ }
3068
+
3069
+ // THE PROFILES (D5): each --profile file is a document whose root is
3070
+ // `profile: {lang, ...}`, evaluated under the verb's trust and vetted
3071
+ // against aontu:profile as a settled value before the fold reads it
3072
+ // (renderProfile, which also fills the defaults). Two files claiming
3073
+ // one lang is a usage error: the fold could not choose.
3074
+ const profiles: any[] = []
3075
+ const langs = new Map<string, string>()
3076
+ for (const pf of profileFiles) {
3077
+ let text: string
3078
+ try {
3079
+ text = readFileSync(pf, 'utf8')
3080
+ }
3081
+ catch (err: any) {
3082
+ process.stderr.write(`aontu: cannot read ${err.path}: ${err.message}\n`)
3083
+ return 2
3084
+ }
3085
+ const loaded = renderProfile(text,
3086
+ { path: resolve(pf), ...verbOpts(trust, entryRootOf(pf)) })
3087
+ if (undefined !== loaded.errors) {
3088
+ process.stderr.write(loaded.errors.map(renderFinding).join('\n') + '\n')
3089
+ return 4
3090
+ }
3091
+ const profile = loaded.profile
3092
+ const prev = langs.get(profile.lang)
3093
+ if (undefined !== prev) {
3094
+ process.stderr.write(
3095
+ `aontu: two profiles claim ${profile.lang}: ${prev} and ${pf}\n`)
3096
+ return 2
3097
+ }
3098
+ langs.set(profile.lang, pf)
3099
+ profiles.push(profile)
3100
+ }
3101
+
3102
+ const report = render(src, {
3103
+ at, unit, strict, profiles, path: files[0],
3104
+ coverage, coverageAt,
3105
+ // THE JSON REPORT CARRIES THE TRACE (D9), which is what the shape
3106
+ // there has always said; a text run computes it only when the
3107
+ // coverage report needs it.
3108
+ trace: 'json' === format,
3109
+ ...verbOpts(trust, entryRootOf(files[0])),
3110
+ })
3111
+
3112
+ if ('json' === format) {
3113
+ process.stdout.write(exactJSON({
3114
+ aontu: { version: version(), verb: 'render' },
3115
+ verdict: report.verdict,
3116
+ units: report.units,
3117
+ lossy: report.lossy,
3118
+ ...(null == report.errors ? {} : { errors: report.errors }),
3119
+ ...(null == report.trace ? {} : { trace: report.trace }),
3120
+ ...(null == report.coverage ? {} : { coverage: report.coverage }),
3121
+ }, 2) + '\n')
3122
+ return renderExit(report, 0)
3123
+ }
3124
+ if ('error' === report.verdict) {
3125
+ process.stderr.write(
3126
+ (report.errors as VetFinding[]).map(renderFinding).join('\n') + '\n')
3127
+ return renderExit(report, 0)
3128
+ }
3129
+
3130
+ let drift = 0
3131
+ if (toStdout) {
3132
+ // ONE UNIT'S BYTES AND NOTHING ELSE, so the output can be piped
3133
+ // into a formatter or a file.
3134
+ if (1 !== report.units.length) {
3135
+ process.stderr.write(
3136
+ 'aontu: --stdout needs exactly one unit, and the instance has ' +
3137
+ `${report.units.length}; --unit names one\n`)
3138
+ return 2
3139
+ }
3140
+ process.stdout.write(report.units[0].text)
3141
+ }
3142
+ else if (undefined !== out) {
3143
+ // EVERY UNIT BELOW <dir>, OR NOTHING: every unit rendered first
3144
+ // (the report above), and no file touched unless all did. The
3145
+ // directory is realpath-confined; a unit path is already a relative
3146
+ // descent (render_path refuses the rest), and the check here is
3147
+ // against the symlink inside it. render never deletes.
3148
+ for (const u of report.units) {
3149
+ if (outsideRoot(out, resolve(out, u.path))) {
3150
+ process.stderr.write(`aontu: ${u.path} escapes ${out}\n`)
3151
+ return 2
3152
+ }
3153
+ }
3154
+ for (const u of report.units) {
3155
+ const full = resolve(out, u.path)
3156
+ try {
3157
+ mkdirSync(dirname(full), { recursive: true })
3158
+ writeFileSync(full, u.text, 'utf8')
3159
+ }
3160
+ catch (err: any) {
3161
+ process.stderr.write(`aontu: cannot write ${u.path}: ${err.message}\n`)
3162
+ return 2
3163
+ }
3164
+ process.stderr.write(`wrote ${u.path}\n`)
3165
+ }
3166
+ }
3167
+ else if (undefined !== check) {
3168
+ // RENDER AND COMPARE: a unit whose bytes differ from the file at
3169
+ // <dir>/<path>, or whose file is absent, is drift, listed by path.
3170
+ // The CI form.
3171
+ for (const u of report.units) {
3172
+ let have: string | undefined = undefined
3173
+ try {
3174
+ have = readFileSync(resolve(check, u.path), 'utf8')
3175
+ }
3176
+ catch {
3177
+ // Absent is drift, reported below.
3178
+ }
3179
+ if (undefined === have) {
3180
+ drift++
3181
+ process.stderr.write(`aontu: ${u.path} is missing from ${check}\n`)
3182
+ }
3183
+ else if (have !== u.text) {
3184
+ drift++
3185
+ process.stderr.write(`aontu: ${u.path} differs from the rendered unit\n`)
3186
+ }
3187
+ }
3188
+ }
3189
+ else if (coverage) {
3190
+ // THE COVERAGE REPORT (P7), one line per finding and a count at
3191
+ // the end: dead model first, then the declarations no rule
3192
+ // produced. A clean report is the count line alone.
3193
+ const cov = report.coverage as RenderCoverage
3194
+ for (const d of cov.dead) {
3195
+ process.stdout.write(`dead: ${d}\n`)
3196
+ }
3197
+ for (const u of cov.unruled) {
3198
+ process.stdout.write(`unruled: ${u.unit} ${u.path}\n`)
3199
+ }
3200
+ process.stdout.write(
3201
+ `coverage: ${cov.read.length} path(s) read, ${cov.dead.length} ` +
3202
+ `no output consumed, ${cov.unruled.length} declaration(s) ` +
3203
+ 'no rule produced\n')
3204
+ }
3205
+ else {
3206
+ // THE SUMMARY: one line per unit -- its path, its language and its
3207
+ // size -- since several units have no one text to print.
3208
+ for (const u of report.units) {
3209
+ process.stdout.write(`${u.path}\t${u.lang}\t${u.text.length} bytes\n`)
3210
+ }
3211
+ }
3212
+ for (const l of report.lossy) {
3213
+ process.stderr.write(
3214
+ `lossy: ${l.unit} ${l.path} tier ${l.tier} ${l.construct}: ${l.reason}\n`)
3215
+ }
3216
+ return renderExit(report, drift)
3217
+ }
3218
+
3219
+ // D8's exit table over a report: a refused unit path is usage (2), a
3220
+ // strict refusal is lossy (1), any other error is the document's (4);
3221
+ // drift under --check is 1.
3222
+ function renderExit(report: RenderReport, drift: number): number {
3223
+ if ('error' === report.verdict) {
3224
+ const errors = report.errors as VetFinding[]
3225
+ if (errors.every((f) => 'render_path' === f.code)) {
3226
+ return 2
3227
+ }
3228
+ if (errors.every((f) => 'render_strict' === f.code)) {
3229
+ return 1
3230
+ }
3231
+ return 4
3232
+ }
3233
+ return 0 < drift ? 1 : 0
3234
+ }
3235
+
3236
+
3237
+ // ---------------------------------------------------------------------
3238
+ // THE TEMPLATE SURFACE (docs/design/TEMPLATE.0.md; RENDER.0.md P8): the
3239
+ // two transforms and the round trip between them. `render` reads a
3240
+ // template directly, by its extension; this verb is for seeing the
3241
+ // canonical form, for writing one by hand and sugaring it, and for the
3242
+ // check that keeps a committed template and its meaning in agreement.
3243
+
3244
+ const TEMPLATE_HELP =
3245
+ 'aontu template [--resugar] [--check] [--marker <token>] <file> (try --help)'
3246
+
3247
+ function runTemplate(argv: string[]): number {
3248
+ const files: string[] = []
3249
+ let resugar = false
3250
+ let check = false
3251
+ let marker: string | undefined = undefined
3252
+
3253
+ for (let i = 0; i < argv.length; i++) {
3254
+ const arg = argv[i]
3255
+ if ('-h' === arg || '--help' === arg) {
3256
+ process.stdout.write(HELP)
3257
+ return 0
3258
+ }
3259
+ else if ('--resugar' === arg) {
3260
+ resugar = true
3261
+ }
3262
+ else if ('--check' === arg) {
3263
+ check = true
3264
+ }
3265
+ else if ('--marker' === arg) {
3266
+ marker = argv[++i]
3267
+ if (null == marker) {
3268
+ process.stderr.write('aontu: --marker needs a token\n')
3269
+ return 2
3270
+ }
3271
+ }
3272
+ else if (arg.startsWith('-')) {
3273
+ process.stderr.write(
3274
+ `aontu: unknown template option ${arg} (try --help)\n`)
3275
+ return 2
3276
+ }
3277
+ else {
3278
+ files.push(arg)
3279
+ }
3280
+ }
3281
+
3282
+ if (1 !== files.length) {
3283
+ process.stderr.write(`aontu: template needs one file\n${TEMPLATE_HELP}\n`)
3284
+ return 2
3285
+ }
3286
+ // THE TWO ARE DIRECTIONS, NOT MODES THAT COMPOSE: `--check` reads a
3287
+ // template and asks whether the round trip answers it back, and
3288
+ // `--resugar` reads the canonical form instead. A run cannot be both
3289
+ // at once, because the file is one thing or the other.
3290
+ if (resugar && check) {
3291
+ process.stderr.write(
3292
+ 'aontu: template takes one of --resugar or --check\n')
3293
+ return 2
3294
+ }
3295
+
3296
+ let src: string
3297
+ try {
3298
+ src = readFileSync(files[0], 'utf8')
3299
+ }
3300
+ catch (err: any) {
3301
+ process.stderr.write(`aontu: cannot read ${err.path}: ${err.message}\n`)
3302
+ return 2
3303
+ }
3304
+
3305
+ const mark = marker ?? markerFor(files[0])
3306
+
3307
+ if (check) {
3308
+ // THE ROUND TRIP IS THE CHECK (D6): the file held to the spelling
3309
+ // the two transforms answer. What that names is a marker line the
3310
+ // transform would not have written -- one without its space, or one
3311
+ // whose aontu is indented after the marker rather than before it,
3312
+ // since the marker keeps its own indentation. It does NOT name a
3313
+ // changed body line: a template's whitespace is output, so a
3314
+ // trimmed trailing space is still a valid template and it is
3315
+ // `render --check` against the committed files that catches it.
3316
+ // The first line that differs is the report, since a whole diff of
3317
+ // a generator is the file again.
3318
+ const back = resugarTemplate(desugarTemplate(src, mark), mark)
3319
+ if (back === src) {
3320
+ return 0
3321
+ }
3322
+ const want = back.split('\n')
3323
+ const have = src.split('\n')
3324
+ let n = 0
3325
+ while (n < want.length && n < have.length && want[n] === have[n]) {
3326
+ n++
3327
+ }
3328
+ // THE TWO ARE THE SAME LENGTH, always: each transform maps one
3329
+ // line to one line and applies the same trailing-newline rule, so
3330
+ // `back` has as many lines as `src`. The loop above therefore stops
3331
+ // at a real difference rather than by running out of either -- an
3332
+ // equal prefix all the way to the end IS `back === src`, which
3333
+ // returned above. So both indexes are in range here.
3334
+ process.stderr.write(
3335
+ `aontu: ${files[0]}:${n + 1} is not what the round trip answers\n` +
3336
+ ` have: ${JSON.stringify(have[n])}\n` +
3337
+ ` want: ${JSON.stringify(want[n])}\n`)
3338
+ return 1
3339
+ }
3340
+
3341
+ process.stdout.write(resugar ?
3342
+ resugarTemplate(src, mark) : desugarTemplate(src, mark))
3343
+ return 0
3344
+ }
3345
+
3346
+
2833
3347
  // ---------------------------------------------------------------------
2834
3348
  // The canon-hash (G6 phase 1): the pin an agent, a lockfile or a
2835
3349
  // registry stores for "this module, this meaning". The hash covers the
@@ -3421,13 +3935,17 @@ function runAgentsMd(argv: string[]): number {
3421
3935
  // the form itself is the library's (ts/src/format.ts), and the two
3422
3936
  // ports agree on it row by row in test/spec/fmt.tsv.
3423
3937
 
3424
- const FMT_HELP = 'aontu fmt [-w|-l|--check|-d] <file>... (try --help)'
3938
+ const FMT_HELP = 'aontu fmt [-w|-l|--check|-d|--lint] <file>... (try --help)'
3425
3939
 
3426
- type FmtFlags = { write: boolean, list: boolean, check: boolean, diff: boolean }
3940
+ type FmtFlags = {
3941
+ write: boolean, list: boolean, check: boolean, diff: boolean, lint: boolean, strict: boolean,
3942
+ }
3427
3943
 
3428
3944
  function runFmt(argv: string[]): number | Promise<number> {
3429
3945
  const files: string[] = []
3430
- const flags: FmtFlags = { write: false, list: false, check: false, diff: false }
3946
+ const flags: FmtFlags = {
3947
+ write: false, list: false, check: false, diff: false, lint: false, strict: false,
3948
+ }
3431
3949
 
3432
3950
  for (const arg of argv) {
3433
3951
  if ('-h' === arg || '--help' === arg) {
@@ -3446,6 +3964,13 @@ function runFmt(argv: string[]): number | Promise<number> {
3446
3964
  else if ('-d' === arg || '--diff' === arg) {
3447
3965
  flags.diff = true
3448
3966
  }
3967
+ else if ('--lint' === arg) {
3968
+ flags.lint = true
3969
+ }
3970
+ else if ('--strict' === arg) {
3971
+ flags.lint = true
3972
+ flags.strict = true
3973
+ }
3449
3974
  else if (arg.startsWith('-')) {
3450
3975
  process.stderr.write(`aontu: unknown fmt option ${arg} (try --help)\n`)
3451
3976
  return 2
@@ -3477,12 +4002,29 @@ function runFmt(argv: string[]): number | Promise<number> {
3477
4002
  if (1 < files.length && !fmtQuiet(flags)) {
3478
4003
  process.stderr.write(
3479
4004
  `aontu: fmt prints one file; with ${files.length}, say --write, ` +
3480
- `--list, --check or --diff\n${FMT_HELP}\n`)
4005
+ `--list, --check, --diff or --lint\n${FMT_HELP}\n`)
3481
4006
  return 2
3482
4007
  }
3483
4008
 
3484
4009
  let worst = 0
3485
4010
  for (const file of files) {
4011
+ // FMT FORMATS AONTU SOURCE, AND THE EXTENSION SAYS WHAT A FILE IS
4012
+ // (ADR-012's rule, and the one `render` reads a template by).
4013
+ // A TEMPLATE FILE IS NOT AONTU (docs/design/TEMPLATE.0.md; P8):
4014
+ // its marker lines are fragments of a document and its other lines
4015
+ // are the target's, so there is nothing here to format that would
4016
+ // not also rewrite the output. Refused rather than attempted, and
4017
+ // refused BY NAME rather than by a parse failure, because a `#-`
4018
+ // template parses: `#` opens a comment, so every marker line
4019
+ // vanishes and what is left is read as a document that was never
4020
+ // written. The verb answered `0` over one, having understood none
4021
+ // of it.
4022
+ if (!/[.](aon|aontu)$/.test(file)) {
4023
+ process.stderr.write(
4024
+ `aontu: ${file} is not aontu source (.aon, .aontu); a generator ` +
4025
+ 'written in the target\'s own syntax is aontu template\'s\n')
4026
+ return 2
4027
+ }
3486
4028
  let src: string
3487
4029
  try {
3488
4030
  src = readFileSync(file, 'utf8')
@@ -3496,28 +4038,34 @@ function runFmt(argv: string[]): number | Promise<number> {
3496
4038
  return worst
3497
4039
  }
3498
4040
 
3499
- // An option that says what to do with a file that would change, in
3500
- // place of printing it.
4041
+ // An option that says what to do with a file, in place of printing
4042
+ // it: what to do when its form would change, or the lint.
3501
4043
  function fmtQuiet(flags: FmtFlags): boolean {
3502
- return flags.write || flags.list || flags.check || flags.diff
4044
+ return flags.write || flags.list || flags.check || flags.diff || flags.lint
3503
4045
  }
3504
4046
 
3505
4047
  // One document: 0 printed, clean or done; 1 a --check that would
3506
- // change; 2 a file that cannot be written; 4 a document that does not
3507
- // format, with the finding that says why.
4048
+ // change, or a --strict finding; 2 a file that cannot be written; 4 a
4049
+ // document that does not format, with the finding that says why. The
4050
+ // style findings go to standard error, one line each, in the shape
4051
+ // every linter prints: `file:line:col: rule: message`.
3508
4052
  function fmtOne(name: string, src: string, flags: FmtFlags): number {
3509
- const report = format(src, { path: name })
4053
+ const report = format(src, { path: name, lint: flags.lint })
3510
4054
  if ('error' === report.verdict) {
3511
4055
  process.stderr.write(`aontu: ${name} was not formatted\n` +
3512
4056
  report.errors.map(renderFinding).join('\n') + '\n')
3513
4057
  return 4
3514
4058
  }
4059
+ for (const f of report.findings) {
4060
+ process.stderr.write(`${name}:${f.line}:${f.col}: ${f.rule}: ${f.message}\n`)
4061
+ }
4062
+ const strict = flags.strict && 0 < report.findings.length ? 1 : 0
3515
4063
  if (!fmtQuiet(flags)) {
3516
4064
  process.stdout.write(report.text)
3517
4065
  return 0
3518
4066
  }
3519
4067
  if (!report.changed) {
3520
- return 0
4068
+ return strict
3521
4069
  }
3522
4070
  if (flags.list || flags.check) {
3523
4071
  process.stdout.write(name + '\n')
@@ -3534,7 +4082,49 @@ function fmtOne(name: string, src: string, flags: FmtFlags): number {
3534
4082
  return 2
3535
4083
  }
3536
4084
  }
3537
- return flags.check ? 1 : 0
4085
+ return flags.check ? 1 : strict
4086
+ }
4087
+
4088
+
4089
+ // ---------------------------------------------------------------------
4090
+ // The servers as verbs: `aontu lsp` runs the language server and
4091
+ // `aontu mcp` the MCP server over the CLI's own streams, so the one
4092
+ // command on PATH is the editor's and the agent's server too, and a
4093
+ // version manager (docs/design/ENV.0.md) has one thing to resolve. The
4094
+ // standalone bins (aontu-lsp, aontu-mcp) run the same functions. Each
4095
+ // server owns its exit; the CLI only dispatches. The pair is a
4096
+ // parameter of main so that a test can see the dispatch without a
4097
+ // server taking the test's own stdin.
4098
+
4099
+ type Servers = {
4100
+ lsp: () => void
4101
+ mcp: (argv: string[]) => void
4102
+ }
4103
+
4104
+ // The real pair takes the process's own stdin and stdout, which no
4105
+ // in-process test can lend it; the executable-entry tests in
4106
+ // cli.test.ts run each through a child process instead, so these two
4107
+ // lines are excluded from the in-process count, as the stdio wiring
4108
+ // of lsp-server.ts is.
4109
+ /* node:coverage ignore next 4 */
4110
+ const SERVERS: Servers = {
4111
+ lsp: () => void lspMain(),
4112
+ mcp: (argv) => void mcpMain(undefined, undefined, undefined, undefined, argv),
4113
+ }
4114
+
4115
+ // undefined: the server took the process; a number: an answer the CLI
4116
+ // gives itself, --help or a usage error.
4117
+ function runLsp(argv: string[], servers: Servers): number | undefined {
4118
+ for (const arg of argv) {
4119
+ if ('-h' === arg || '--help' === arg) {
4120
+ process.stdout.write(HELP)
4121
+ return 0
4122
+ }
4123
+ process.stderr.write(`aontu: lsp takes no arguments (try --help)\n`)
4124
+ return 2
4125
+ }
4126
+ servers.lsp()
4127
+ return undefined
3538
4128
  }
3539
4129
 
3540
4130
 
@@ -3562,7 +4152,7 @@ function parseTrustArg(value: string): TrustArg | undefined {
3562
4152
  }
3563
4153
 
3564
4154
 
3565
- function main(argv: string[]): void {
4155
+ function main(argv: string[], servers: Servers = SERVERS): void {
3566
4156
  // COLOUR OFF WHEN THE DESTINATION IS NOT A TERMINAL. Error frames
3567
4157
  // hardcoded their ANSI escapes, so a piped report and a `--jsonl`
3568
4158
  // answer carried terminal control codes into whatever read them (the
@@ -3612,6 +4202,13 @@ function main(argv: string[]): void {
3612
4202
  if ('fmt' === argv[2]) {
3613
4203
  return void Promise.resolve(runFmt(argv.slice(3))).then(finish)
3614
4204
  }
4205
+ if ('lsp' === argv[2]) {
4206
+ const code = runLsp(argv.slice(3), servers)
4207
+ return undefined === code ? undefined : finish(code)
4208
+ }
4209
+ if ('mcp' === argv[2]) {
4210
+ return servers.mcp(argv.slice(3))
4211
+ }
3615
4212
 
3616
4213
  if ('set' === argv[2]) {
3617
4214
  return finish(runSet(argv.slice(3)))
@@ -3641,6 +4238,14 @@ function main(argv: string[]): void {
3641
4238
  return finish(runJsonSchema(argv.slice(3)))
3642
4239
  }
3643
4240
 
4241
+ if ('render' === argv[2]) {
4242
+ return finish(runRender(argv.slice(3)))
4243
+ }
4244
+
4245
+ if ('template' === argv[2]) {
4246
+ return finish(runTemplate(argv.slice(3)))
4247
+ }
4248
+
3644
4249
  if ('reaches' === argv[2]) {
3645
4250
  return finish(runReaches(argv.slice(3)))
3646
4251
  }
@@ -3747,7 +4352,7 @@ function main(argv: string[]): void {
3747
4352
  else {
3748
4353
  runStdin(mode, trust).then((code) => finish(code))
3749
4354
  }
3750
- } /* node:coverage ignore next 16 */
4355
+ } /* node:coverage ignore next 18 */
3751
4356
 
3752
4357
 
3753
4358
  // No require.main guard here: bin/aontu.js is the executable entry and
@@ -3759,6 +4364,8 @@ export {
3759
4364
  runReaches,
3760
4365
  runView,
3761
4366
  runJsonSchema,
4367
+ runRender,
4368
+ runTemplate,
3762
4369
  runMod,
3763
4370
  runHash, runGet,
3764
4371
  runWhy, renderWhyText, runSet, runAgentsMd, runFmt,