aontu 0.65.0 → 0.67.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.
@@ -14,16 +14,14 @@
14
14
  "rejects AS THOUGH it were valid, which is why the builtin list below is",
15
15
  "the engine's own set and not a hand-picked sample of it.",
16
16
  "",
17
- "THE BUILTIN LIST IS A COPY, AND A COPY DRIFTS. aontu.lark carried",
18
- "`same` — never a builtin — and was missing `acyclic`, `inverse` and",
19
- "`rel` for two releases, with every test green. So the same guard the",
17
+ "THE BUILTIN LIST IS A COPY, AND A COPY DRIFTS, so the same guard the",
20
18
  "other two grammars have covers this file: ts/test/grammar.test.ts,",
21
19
  "`the-textmate-grammar-names-exactly-the-engine-builtins`, asserts this",
22
20
  "list against BUILTIN_FUNCS in BOTH directions. Add a builtin to the",
23
21
  "engine without adding it here and that test fails.",
24
22
  "",
25
- "No `|>`: ADR-018 removed the pipe from the language, and all three",
26
- "published grammars now agree about that."
23
+ "No `|>`: the pipe is not in the language, and all three published",
24
+ "grammars agree about that."
27
25
  ],
28
26
  "name": "aontu",
29
27
  "displayName": "Aontu",
@@ -130,7 +128,7 @@
130
128
  "match": "\\b(true|false|null)\\b"
131
129
  },
132
130
  {
133
- "comment": "`_` is a literal of the language as of G8 phase 3, not text.",
131
+ "comment": "`_` is a literal of the language, not text.",
134
132
  "name": "constant.language.placeholder.aontu",
135
133
  "match": "(?<![A-Za-z0-9_])_(?![A-Za-z0-9_])"
136
134
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aontu",
3
- "version": "0.65.0",
3
+ "version": "0.67.0",
4
4
  "main": "dist/aontu.js",
5
5
  "type": "commonjs",
6
6
  "types": "dist/aontu.d.ts",
@@ -38,7 +38,7 @@
38
38
  "watch": "tsc --build src test -w",
39
39
  "build": "tsc --build src test",
40
40
  "prepack": "node scripts/prepack.js",
41
- "clean": "rm -rf dist dist-test node_modules yarn.lock package-lock.json",
41
+ "clean": "rm -rf dist dist-test .tsbuild node_modules yarn.lock package-lock.json",
42
42
  "reset": "npm run clean && npm i && npm run build && npm test",
43
43
  "version": "node -e \"const fs=require('fs');const v=require('./package.json').version;const f='./src/aontu.ts';const s=fs.readFileSync(f,'utf8');const re=/^const VERSION = '.*?'/m;if(!re.test(s)){console.error('version: no VERSION constant in '+f);process.exit(1)}const n=s.replace(re,'const VERSION = \\''+v+'\\'');if(n!==s){fs.writeFileSync(f,n);console.log('version: src/aontu.ts VERSION -> '+v)}\"",
44
44
  "repo-bump": "npm version patch --no-git-tag-version && node -e \"console.log('bumped to v' + require('./package.json').version + ' (package.json only - commit via repo-tag / repo-release)')\"",
@@ -78,7 +78,8 @@
78
78
  "@tabnas/parser": "0.9.7",
79
79
  "@tabnas/path": "0.3.2",
80
80
  "@tabnas/toml": "0.5.7",
81
- "@tabnas/yaml": "0.5.7"
81
+ "@tabnas/yaml": "0.5.7",
82
+ "jostraca": "0.38.0"
82
83
  },
83
84
  "devDependencies": {
84
85
  "@tabnas/railroad": "0.3.6",
package/skill/tasks.md CHANGED
@@ -82,16 +82,21 @@ aontu trim --check model.aon # entries whose removal changes nothing
82
82
  ## Generate code from a model, and hold it
83
83
 
84
84
  ```
85
- aontu model get out gen.aon # the component tree: files, and the lines in them
86
- aontu trace gen.aon # what rule wrote each line
85
+ aontu render gen.aon build # write the files below build/
86
+ aontu render --check gen.aon build # write nothing; exit 1 when build/ has drifted
87
+ aontu render gen/ build # every generator directly in gen/, as one run
88
+ aontu model get out gen.aon # the component tree: files, and the lines in them
89
+ aontu trace gen.aon # what rule wrote each line
87
90
  ```
88
91
 
89
92
  A generator answers a **component tree** -- `file(name, [lines])` and
90
- its neighbours -- which a generator runtime writes to disk. Hand the
91
- tree to one and compare with what is committed: the model is the truth,
92
- the code is the claim, and drift is a finding. `aontu trace` answers
93
- the other direction: for a line of the output, the model node and the
94
- rule behind it.
93
+ its neighbours -- which `aontu render` hands to jostraca, the generator
94
+ runtime, to write. A tree that is one file is written to the path
95
+ itself, and a folder of generators is one run. `--check` compares with
96
+ what is committed: the model is the
97
+ truth, the code is the claim, and drift is a finding. `aontu trace`
98
+ answers the other direction: for a line of the output, the model node
99
+ and the rule behind it.
95
100
 
96
101
  ## Ask what a model says, and why
97
102
 
package/src/allow.ts CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  import { Aontu } from './aontu'
5
5
  import { ConjunctVal } from './val/ConjunctVal'
6
- import { anchorAt } from './vet'
6
+ import { anchorAt, engineFinding } from './vet'
7
7
  import type { VetFinding } from './vet'
8
8
  import { evalFailure, nearestKey, pathParts } from './query'
9
9
  import { includeOpts } from './utility'
@@ -101,7 +101,7 @@ function readEntries(
101
101
  const text: string = true === el.isString ? el.peg : el.gen(ctx)
102
102
  if (before < ctx.err.length) {
103
103
  const err: any = ctx.err[before]
104
- return { entries, finding: finding(err.why, `${by}.${i}`, err.msg) }
104
+ return { entries, finding: engineFinding(err, ctx, `${by}.${i}`) }
105
105
  }
106
106
  entries.push({ parts: pathParts(text), text, by: `${by}.${i}` })
107
107
  }
@@ -174,7 +174,7 @@ export function allow(
174
174
  const root: any = aontu.unify(
175
175
  new ConjunctVal({ peg: [model, shape] }, ctx), undefined, ctx)
176
176
  if (0 < ctx.err.length || true === root.isNil) {
177
- return errorReport(role, evalFailure(ctx))
177
+ return errorReport(role, evalFailure(ctx, root))
178
178
  }
179
179
 
180
180
  const roles: any = anchorAt(root, at)
package/src/aontu.ts CHANGED
@@ -35,7 +35,7 @@ import { format, unifiedDiff } from './format'
35
35
  export type { LintFinding, FormatReport, FormatOptions } from './format'
36
36
 
37
37
 
38
- const VERSION = '0.65.0'
38
+ const VERSION = '0.67.0'
39
39
 
40
40
 
41
41
  function genQuiet(val: any, aontu: Aontu): any {
package/src/cli.ts CHANGED
@@ -5,8 +5,10 @@
5
5
  import { evalFailure } from './query'
6
6
  // __importStar downlevel helper, whose branches no supported Node takes.
7
7
  import {
8
- existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync,
8
+ existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync,
9
+ writeFileSync,
9
10
  } from 'node:fs'
11
+ import type { Dirent } from 'node:fs'
10
12
  import { basename, dirname, join, resolve } from 'node:path'
11
13
  import { tmpdir } from 'node:os'
12
14
  import { createInterface } from 'node:readline'
@@ -97,6 +99,9 @@ const HELP = `Usage: aontu [options] [file]
97
99
  [--profile <file>] <file>
98
100
  aontu trace [--at <path>] [--format json] [--marker <token>]
99
101
  [--profile <file>] <file>
102
+ aontu render [--check] [--at <path>] [--format json]
103
+ [--marker <token>] [--profile <file>]
104
+ <file|folder> <path>
100
105
  aontu hash [options] <file>
101
106
  aontu sync [--frozen] [options] [dir]
102
107
  aontu add <pkg>[@<version>] [options] [dir]
@@ -161,22 +166,30 @@ query between a document and its own earlier versions.
161
166
 
162
167
  Options:
163
168
  -c, --canon Print the canonical form instead of generated JSON
164
- --format <f> text (default) or json. The json form wraps the
165
- answer as {aontu, findings, ok, out}, so a failure
166
- here reads like every other verb's
169
+ (the bare command's, as --jsonl is; model get has
170
+ its own)
171
+ --format <f> text (default) or json, on every verb that answers a
172
+ report. The json form is one object opening with an
173
+ aontu block; the bare command's carries findings, ok
174
+ and out
167
175
  -h, --help Show this help and exit (the verbs and their flags);
168
176
  aontu help is the LANGUAGE, and lists its own topics
169
177
  --jsonl REPL: answer every command as one JSON line
170
178
  -v, --version Print the version and exit
171
- --trust <t> Include capability: system (default), none, or
172
- root[:dir] to confine @"..." below a directory.
173
- Every verb takes it too, and a bare root means the
174
- document's own directory
179
+ --trust <t> Include capability: system, none, or root[:dir] to
180
+ confine @"..." below a directory. A bare root means
181
+ the entry root: the document's own directory, or the
182
+ project's for the package verbs. Every verb takes it
183
+ but help, explain, init and lsp, which read no
184
+ document, and mcp, which is the npm build's server
185
+ and confines with --root. Unset, a run behaves as
186
+ system, and the bare command warns once for an
187
+ include that leaves the entry root
175
188
  --include-root <dir> Shorthand for --trust root:<dir>
176
- --text-ext <e> Read these extensions as text too, comma-separated
177
- and without dots (md,sql). .txt needs no flag; a
189
+ --text-ext <e> Read these extensions as text too, comma-separated,
190
+ with or without dots (md,sql). .txt needs no flag; a
178
191
  named format keeps its meaning, and .js stays
179
- refused. Every verb takes it
192
+ refused. The verbs that take --trust take it too
180
193
 
181
194
  Package verbs (a module is imported; a package is published):
182
195
  sync Make the project correct: resolve by minimum version
@@ -411,6 +424,31 @@ every other line is a line of output.
411
424
 
412
425
  Template exit codes: 0 written, 1 --check drift, 2 usage or I/O.
413
426
 
427
+ Render options:
428
+ --check Compare what the generator writes with what <path>
429
+ holds, write nothing, and exit 1 on drift
430
+ --at <path> Where the component tree lives in the document
431
+ (default $.out)
432
+ --format json Print the files written, or the drift, as JSON
433
+ --marker <t> The file is a generator, and this is its marker
434
+ (default //-, and #- --- /*- <!--- by extension)
435
+ --profile <f> A profile file, whose template.ext names the
436
+ extensions it marks and template.marker the marker
437
+
438
+ The render verb writes the component tree a generator answers. The
439
+ tree is handed to jostraca, the generator runtime, which writes the
440
+ files: a tree that is one file is written to <path> itself, unless
441
+ <path> is a directory, and any other tree is written below <path>.
442
+ With --check nothing is written and <path> is compared with what the
443
+ generator writes, one "kind: file" line per difference.
444
+ A folder as the generator is a set: every regular file directly in
445
+ it, dotfiles aside, in name order, and their trees are written below
446
+ <path> as one run, so a path two of them claim is refused. A file with
447
+ no marker line in it is refused by name.
448
+
449
+ Render exit codes: 0 written or clean, 1 --check drift, 2 usage or
450
+ I/O, 4 the document does not stand up, or --at names nothing.
451
+
414
452
  Model set options:
415
453
  --entry <file> The document the change is checked against
416
454
  --overlay <file> The file the change is appended to (created if
@@ -678,7 +716,7 @@ function takeTrust(argv: string[], io: Io = PROCESS_IO):
678
716
  }
679
717
  else if ('--include-root' === arg) {
680
718
  const dir = argv[++i]
681
- if (null == dir) {
719
+ if (null == dir || '' === dir) {
682
720
  io.err('aontu: --include-root needs a directory\n')
683
721
  return undefined
684
722
  }
@@ -2674,20 +2712,42 @@ function pkgNetText(verb: string, report: any): string {
2674
2712
  const MODEL_HELP = 'aontu model get|why|set ... (try --help)'
2675
2713
 
2676
2714
  // One document, interrogated or edited (ADR-039 part 5).
2715
+ // Where the subcommand is, past any global flag that precedes it.
2716
+ // `takeTrust` strips those anywhere in a tail, so the subcommand has
2717
+ // to be found past a flag AND past its value: in `--text-ext get` the
2718
+ // `get` is the extension list, not the subcommand.
2719
+ const MODEL_SUBS = ['get', 'why', 'set']
2720
+ const GLOBAL_VALUED = ['--trust', '--include-root', '--text-ext']
2721
+
2722
+
2723
+ function modelSubAt(argv: string[]): number {
2724
+ for (let i = 0; i < argv.length; i++) {
2725
+ if (GLOBAL_VALUED.includes(argv[i])) {
2726
+ i++
2727
+ continue
2728
+ }
2729
+ return MODEL_SUBS.includes(argv[i]) ? i : -1
2730
+ }
2731
+ return -1
2732
+ }
2733
+
2734
+
2677
2735
  function runModel(argv: string[]): number {
2678
2736
  const sub = argv[0]
2679
2737
  if ('-h' === sub || '--help' === sub) {
2680
2738
  process.stdout.write(HELP)
2681
2739
  return 0
2682
2740
  }
2683
- if ('get' === sub) {
2684
- return runGet(argv.slice(1))
2685
- }
2686
- if ('why' === sub) {
2687
- return runWhy(argv.slice(1))
2688
- }
2689
- if ('set' === sub) {
2690
- return runSet(argv.slice(1))
2741
+ const at = modelSubAt(argv)
2742
+ if (0 <= at) {
2743
+ const tail = argv.slice(0, at).concat(argv.slice(at + 1))
2744
+ if ('get' === argv[at]) {
2745
+ return runGet(tail)
2746
+ }
2747
+ if ('why' === argv[at]) {
2748
+ return runWhy(tail)
2749
+ }
2750
+ return runSet(tail)
2691
2751
  }
2692
2752
  process.stderr.write(`aontu: model needs get, why or set\n${MODEL_HELP}\n`)
2693
2753
  return 2
@@ -2880,6 +2940,297 @@ function runTrace(argv: string[]): number {
2880
2940
  }
2881
2941
 
2882
2942
 
2943
+ // ---------------------------------------------------------------------
2944
+ // The writer (ADR-040). A generator answers a component tree; this verb
2945
+ // hands it to jostraca, which writes the files below a path or holds
2946
+ // them to it.
2947
+
2948
+ const RENDER_HELP =
2949
+ 'aontu render [--check] [--at <path>] [--format json] ' +
2950
+ '[--marker <token>] [--profile <file>] <file|folder> <path> (try --help)'
2951
+
2952
+
2953
+ // Loaded at the call, so every other verb starts without it.
2954
+ function generatorRuntime(): any {
2955
+ return require('jostraca')
2956
+ }
2957
+
2958
+
2959
+ function isDirectory(path: string): boolean {
2960
+ return true === statSync(path, { throwIfNoEntry: false })?.isDirectory()
2961
+ }
2962
+
2963
+
2964
+ // A `File` the write path skips, RENAMED rather than removed: dropping
2965
+ // the node would take its children's claims with it and hide drift the
2966
+ // write path does make. The skip is the File's own save alone, so what
2967
+ // separates the runs is its output path, composed by the runtime rather
2968
+ // than here.
2969
+ const EXCLUDED_NAME = '.aontu-check-excluded-'
2970
+
2971
+
2972
+ // `exclude` as the runtime reads it: `true`, or a string or list member
2973
+ // equal to the node's COMPONENT path, the chain of `name` props above
2974
+ // it, which a Project's `folder` is not part of. The Go runtime honours
2975
+ // the boolean alone, so the ports' `--check` answers differ for the
2976
+ // path forms (test/spec/divergent.tsv).
2977
+ function excludedFile(exclude: any, at: string[]): boolean {
2978
+ if (true === exclude) {
2979
+ return true
2980
+ }
2981
+ const path = at.join('/')
2982
+ if ('string' === typeof exclude) {
2983
+ return exclude === path
2984
+ }
2985
+ return Array.isArray(exclude) && exclude.includes(path)
2986
+ }
2987
+
2988
+
2989
+ function renameExcluded(node: any, at: string[], cut: number[]): any {
2990
+ if (Array.isArray(node)) {
2991
+ return node.map((child) => renameExcluded(child, at, cut))
2992
+ }
2993
+ // A hand-written tree carries nodes with no `props` and nodes with
2994
+ // no `children`, and neither needs an arm of its own.
2995
+ const props: any = node.props ?? {}
2996
+ const below = 'string' === typeof props.name ? at.concat(props.name) : at
2997
+ if ('File' === node.cmp && excludedFile(props.exclude, below)) {
2998
+ cut.push(1)
2999
+ return { ...node, props: { ...props, name: EXCLUDED_NAME + cut.length } }
3000
+ }
3001
+ if (!Array.isArray(node.children)) {
3002
+ return node
3003
+ }
3004
+ return { ...node, children: renameExcluded(node.children, below, cut) }
3005
+ }
3006
+
3007
+
3008
+ // A file the runtime declined to touch, its copy on disk carrying the
3009
+ // protect marker, is in none of the lists it answers with: they hold
3010
+ // what was DONE to a file, and nothing was. The run's own record names
3011
+ // it. `since` drops what earlier runs left.
3012
+ function renderSkipped(folder: string, since: number): string[] {
3013
+ const at = join(folder, '.jostraca', 'jostraca.meta.log')
3014
+ let meta: any
3015
+ try {
3016
+ meta = JSON.parse(readFileSync(at, 'utf8'))
3017
+ }
3018
+ catch (err: any) {
3019
+ return []
3020
+ }
3021
+ return Object.entries(meta?.files ?? {})
3022
+ .filter(([_, f]: [string, any]) => 'skip' === f?.action && since <= f?.when)
3023
+ .map(([path]) => path)
3024
+ .sort(cmpCodePoint)
3025
+ }
3026
+
3027
+
3028
+ async function runRender(argv: string[]): Promise<number> {
3029
+ const trusted = takeTrust(argv)
3030
+ if (null == trusted) {
3031
+ return 2
3032
+ }
3033
+ argv = trusted.argv
3034
+ const trust = trusted.trust
3035
+ const rest: string[] = []
3036
+ const profileFiles: string[] = []
3037
+ let format: SubsumeFormat = 'text'
3038
+ let at: string | undefined = undefined
3039
+ let marker: string | undefined = undefined
3040
+ let check = false
3041
+
3042
+ for (let i = 0; i < argv.length; i++) {
3043
+ const arg = argv[i]
3044
+ if ('-h' === arg || '--help' === arg) {
3045
+ process.stdout.write(HELP)
3046
+ return 0
3047
+ }
3048
+ if ('--check' === arg) {
3049
+ check = true
3050
+ }
3051
+ else if ('--format' === arg) {
3052
+ const f = argv[++i]
3053
+ if ('text' !== f && 'json' !== f) {
3054
+ process.stderr.write('aontu: --format needs text or json\n')
3055
+ return 2
3056
+ }
3057
+ format = f
3058
+ }
3059
+ else if ('--at' === arg) {
3060
+ at = argv[++i]
3061
+ if (null == at || '' === at) {
3062
+ process.stderr.write('aontu: --at needs a path\n')
3063
+ return 2
3064
+ }
3065
+ }
3066
+ else if ('--marker' === arg) {
3067
+ marker = argv[++i]
3068
+ if (null == marker || '' === marker) {
3069
+ process.stderr.write('aontu: --marker needs a token\n')
3070
+ return 2
3071
+ }
3072
+ }
3073
+ else if ('--profile' === arg) {
3074
+ const pf = argv[++i]
3075
+ if (null == pf || '' === pf) {
3076
+ process.stderr.write('aontu: --profile needs a file\n')
3077
+ return 2
3078
+ }
3079
+ profileFiles.push(pf)
3080
+ }
3081
+ else if (arg.startsWith('-')) {
3082
+ process.stderr.write(`aontu: unknown render option ${arg} (try --help)\n`)
3083
+ return 2
3084
+ }
3085
+ else {
3086
+ rest.push(arg)
3087
+ }
3088
+ }
3089
+
3090
+ if (2 !== rest.length) {
3091
+ process.stderr.write(`aontu: render needs a file and a path\n${RENDER_HELP}\n`)
3092
+ return 2
3093
+ }
3094
+ const [file, dest] = rest
3095
+
3096
+ const declared = loadProfiles(profileFiles, trust)
3097
+ if ('number' === typeof declared) {
3098
+ return declared
3099
+ }
3100
+
3101
+ // A FOLDER IS A SET OF GENERATORS: every regular file directly in it,
3102
+ // dotfiles aside, in code-point order, written as one tree.
3103
+ let entries: Dirent[] | undefined
3104
+ try {
3105
+ entries = readdirSync(file, { withFileTypes: true })
3106
+ }
3107
+ catch (err: any) {
3108
+ entries = undefined
3109
+ }
3110
+ const set = undefined !== entries
3111
+ const files = undefined === entries ? [file] :
3112
+ entries.filter((e) => e.isFile() && !e.name.startsWith('.'))
3113
+ .map((e) => e.name).sort(cmpCodePoint).map((n) => join(file, n))
3114
+ if (0 === files.length) {
3115
+ process.stderr.write(`aontu: ${file} holds no generator\n`)
3116
+ return 2
3117
+ }
3118
+
3119
+ const trees: any[] = []
3120
+ for (const f of files) {
3121
+ let src: string
3122
+ try {
3123
+ src = readFileSync(f, 'utf8')
3124
+ }
3125
+ catch (err: any) {
3126
+ process.stderr.write(`aontu: cannot read ${err.path}: ${err.message}\n`)
3127
+ return 2
3128
+ }
3129
+ if (!/[.](aon|aontu)$/.test(f)) {
3130
+ const mark = marker ?? markerFromProfiles(declared, f) ?? markerFor(f)
3131
+ if (!templateOutputs(src, mark).some((out) => !out)) {
3132
+ process.stderr.write(`aontu: ${f} carries no ${mark} marker line, ` +
3133
+ 'so there is no aontu in it to render\n')
3134
+ return 2
3135
+ }
3136
+ src = desugarTemplate(src, mark)
3137
+ }
3138
+ const report = get(src, at ?? '$.out', {
3139
+ view: 'json', path: f, ...verbOpts(trust, entryRootOf(f)),
3140
+ })
3141
+ if (!report.ok) {
3142
+ process.stderr.write(report.findings.map(renderFinding).join('\n') + '\n')
3143
+ return 4
3144
+ }
3145
+ const tree = JSON.parse(report.out)
3146
+ // A `File` without a name is refused: the runtime ports disagree
3147
+ // about it.
3148
+ if ('File' === tree?.cmp && 'string' !== typeof tree.props?.name) {
3149
+ process.stderr.write(`aontu: ${f}: the file at ${at ?? '$.out'} has no name\n`)
3150
+ return 4
3151
+ }
3152
+ trees.push(tree)
3153
+ }
3154
+
3155
+ // ONE FILE GOES TO THE PATH ITSELF, unless the path is a directory; a
3156
+ // set is written below the path whatever its trees are.
3157
+ let folder = dest
3158
+ const tree: any = set ? trees.flatMap((t) => Array.isArray(t) ? t : [t]) : trees[0]
3159
+ if (!set && 'File' === tree?.cmp && !isDirectory(dest)) {
3160
+ tree.props.name = basename(dest)
3161
+ folder = dirname(dest)
3162
+ }
3163
+
3164
+ const { cmpTree, Jostraca } = generatorRuntime()
3165
+ let root: any
3166
+ try {
3167
+ root = cmpTree(tree, { raw: true })
3168
+ }
3169
+ catch (err: any) {
3170
+ process.stderr.write(`aontu: ${file}: ${err.message}\n`)
3171
+ return 4
3172
+ }
3173
+ const runtime = Jostraca()
3174
+
3175
+ try {
3176
+ if (check) {
3177
+ const res = await runtime.check({ folder }, root)
3178
+ let drift = res.drift.map((d: any) => ({ kind: d.kind, path: d.path }))
3179
+ // `--check` answers "would `render` change anything", so a file
3180
+ // the write path leaves alone is not held to the generator's
3181
+ // bytes. The skip is gated on the target BEING there -- `render`
3182
+ // writes an absent one -- so drift at a path with nothing at it
3183
+ // survives, which is the `missing` a deleted file reports.
3184
+ const cut: number[] = []
3185
+ const renamed = renameExcluded(tree, [], cut)
3186
+ if (0 < cut.length) {
3187
+ const kept = new Set<string>((await Jostraca().check(
3188
+ { folder }, cmpTree(renamed, { raw: true }))).checked)
3189
+ const skipped = new Set<string>(
3190
+ res.checked.filter((p: string) => !kept.has(p)))
3191
+ drift = drift.filter((d: any) => !skipped.has(d.path) ||
3192
+ !existsSync(join(folder, d.path)))
3193
+ }
3194
+ if ('json' === format) {
3195
+ process.stdout.write(exactJSON({
3196
+ aontu: { version: version(), verb: 'render' },
3197
+ verdict: 0 === drift.length ? 'ok' : 'drift',
3198
+ checked: res.checked,
3199
+ drift,
3200
+ }, 2) + '\n')
3201
+ }
3202
+ else {
3203
+ for (const d of drift) {
3204
+ process.stdout.write(`${d.kind}: ${d.path}\n`)
3205
+ }
3206
+ }
3207
+ return 0 === drift.length ? 0 : 1
3208
+ }
3209
+
3210
+ const since = Date.now()
3211
+ const res = await runtime.generate({ folder }, root)
3212
+ const skipped = renderSkipped(folder, since)
3213
+ if ('json' === format) {
3214
+ process.stdout.write(exactJSON({
3215
+ aontu: { version: version(), verb: 'render' },
3216
+ verdict: 'ok',
3217
+ files: { ...res.files, skipped },
3218
+ }, 2) + '\n')
3219
+ }
3220
+ else {
3221
+ for (const path of skipped) {
3222
+ process.stdout.write(`skipped: ${path}\n`)
3223
+ }
3224
+ }
3225
+ return 0
3226
+ }
3227
+ catch (err: any) {
3228
+ process.stderr.write(`aontu: ${err.message}\n`)
3229
+ return 2
3230
+ }
3231
+ }
3232
+
3233
+
2883
3234
  function runReaches(argv: string[]): number {
2884
3235
  const trusted = takeTrust(argv)
2885
3236
  if (null == trusted) {
@@ -3697,7 +4048,7 @@ function runHash(argv: string[]): number {
3697
4048
  if (0 < ctx.err.length || true === v?.isNil) {
3698
4049
  process.stderr.write(
3699
4050
  `aontu: ${files[0]} does not evaluate on its own; nothing to hash\n` +
3700
- renderFinding(evalFailure(ctx)) + '\n')
4051
+ renderFinding(evalFailure(ctx, v)) + '\n')
3701
4052
  return 4
3702
4053
  }
3703
4054
 
@@ -4880,8 +5231,8 @@ function runInit(argv: string[]): number {
4880
5231
  const KNOWN_VERBS = [
4881
5232
  'add', 'agentsmd', 'allow', 'breaking', 'explain', 'fmt', 'get', 'hash',
4882
5233
  'help', 'init', 'jsonschema', 'lsp', 'mcp', 'model', 'pkg', 'publish',
4883
- 'reaches', 'relations', 'remove', 'subsume', 'sync', 'template', 'trace',
4884
- 'trim', 'vet', 'view', 'why',
5234
+ 'reaches', 'relations', 'remove', 'render', 'subsume', 'sync', 'template',
5235
+ 'trace', 'trim', 'vet', 'view', 'why',
4885
5236
  ]
4886
5237
 
4887
5238
 
@@ -5029,6 +5380,10 @@ function main(argv: string[], servers: Servers = SERVERS): void {
5029
5380
  return finish(runTrace(argv.slice(3)))
5030
5381
  }
5031
5382
 
5383
+ if ('render' === argv[2]) {
5384
+ return void runRender(argv.slice(3)).then(finish)
5385
+ }
5386
+
5032
5387
  if ('reaches' === argv[2]) {
5033
5388
  return finish(runReaches(argv.slice(3)))
5034
5389
  }
@@ -5083,7 +5438,7 @@ function main(argv: string[], servers: Servers = SERVERS): void {
5083
5438
  }
5084
5439
  else if ('--include-root' === arg) {
5085
5440
  const dir = args[++i]
5086
- if (null == dir) {
5441
+ if (null == dir || '' === dir) {
5087
5442
  process.stderr.write('aontu: --include-root needs a directory\n')
5088
5443
  return finish(2)
5089
5444
  }
@@ -5146,6 +5501,7 @@ export {
5146
5501
  runJsonSchema,
5147
5502
  runTemplate,
5148
5503
  runTrace,
5504
+ runRender, renderSkipped,
5149
5505
  runPkg, runModel, runPackageVerb, pkgToolOptions, serveUntilInterrupted,
5150
5506
  runHash, runGet, runHelp, runExplain, runInit, nearestVerb,
5151
5507
  looksLikeVerb,
package/src/helpdoc.ts CHANGED
@@ -17,7 +17,7 @@ const HELPDOC: HelpTopic[] = [
17
17
  "topic": "tasks",
18
18
  "summary": "what do you want to do? — the verb for a job",
19
19
  "source": "docs/skill/tasks.md",
20
- "text": "# What do you want to do?\n\nThe verb for a job, found by the word you arrived with. aontu's own\nvocabulary is on the right; yours is probably on the left.\n\n## Start from a document that works\n\nNothing written yet? Do not invent the first document, edit one:\n\n```\naontu init # into the working directory\naontu init model/ # or into a named one\nsh check.sh # the four questions, on what was just written\n```\n\n`init` writes `model.aon` (an entity map constrained with `&:`),\n`data.aon` (an instance of it that holds) and `check.sh` (the four\nchecks to run after every edit). It never overwrites: if any\nof the three already stands there, it refuses and writes none of them.\n\n## Describe a domain\n\nAn **ontology**, a **schema**, a **data model**, a **contract** — in\naontu these are all one thing: a document. Write the entities as a\n**map keyed by name**, and say what every entry must satisfy with the\n`&:` template:\n\n```\nentity: {\n &: {\n table: string\n fields: { &: { type: string, required: *false | boolean } }\n }\n planet: { table: \"planets\", fields: { name: { type: \"string\" } } }\n moon: { table: \"moons\", fields: { name: { type: \"string\" } } }\n}\n```\n\n`&:` is the construct to reach for first, and the one most easily\nmissed. It meets **every** key of the map it sits in. A quoted `\"*\"`\nis not a wildcard — it is a key named `*`, and a schema written that\nway constrains nothing while still reporting `valid`.\n\nPrefer a named map to a list unless the order is a fact (a migration\nsequence, a rule table tried in order). A key is an address, a name\nother parts can refer to, and a diff that shows one insertion instead\nof every following element renumbered.\n\n## Check data against a model\n\n```\naontu vet model.aon data.aon\n```\n\nExit `0` valid, `1` a contradiction, `3` incomplete (nothing\ncontradicts, but the model is not yet satisfied), `4` the model does\nnot stand up on its own. `--format json` for the machine-readable\nreport, `--closed` to refuse keys the model does not declare,\n`--max-errors <n>` to cap the list.\n\n**Make the check prove it checked something.** A check that examined\nnothing answers exactly like one that passed:\n\n```\naontu vet --coverage model.aon data.aon # what did it examine?\naontu vet --strict-coverage model.aon data.aon # exit 1 if nothing\n```\n\n`--coverage` reports how many data leaves a declaration constrained,\nthe data paths none did, and the declarations no data met.\n`--strict-coverage` exits 1 when the answer is nothing. The usual\ncause is the `\"*\"` mistake above: reach for `&:`.\n\n## Check the model is coherent with itself\n\n```\naontu relations model.aon # declared entity edges: targets resolve, no cycles\naontu reaches planet moon model.aon # does one entity reach another, at any remove?\naontu trim --check model.aon # entries whose removal changes nothing\n```\n\n## Generate code from a model, and hold it\n\n```\naontu model get out gen.aon # the component tree: files, and the lines in them\naontu trace gen.aon # what rule wrote each line\n```\n\nA generator answers a **component tree** -- `file(name, [lines])` and\nits neighbours -- which a generator runtime writes to disk. Hand the\ntree to one and compare with what is committed: the model is the truth,\nthe code is the claim, and drift is a finding. `aontu trace` answers\nthe other direction: for a line of the output, the model node and the\nrule behind it.\n\n## Ask what a model says, and why\n\n```\naontu model get $.entity.planet.table model.aon\naontu model why $.entity.planet.table model.aon\naontu model get $.entity --keys model.aon\naontu model get $.entity --types model.aon\n```\n\n`why` names **every** contribution to a value, with the file and line\neach was written on. It is the first thing to run when a value is not\nwhat you expected, and the second thing to run when `vet` refuses.\n\n## Change a value without editing the file\n\n```\naontu model set '$.entity.planet.table=planet_v2' --entry model.aon --overlay local.aon\n```\n\nThe change is checked before it is written; a change that contradicts\na pinned value is refused, and the file is left alone.\n\n## Gate a change to the model itself\n\n```\naontu subsume old.aon new.aon # does the general admit every specific?\naontu breaking --against git#HEAD~1 model.aon\naontu hash model.aon # a pin that survives reformatting\n```\n\n## Hand a model to another agent\n\n```\naontu agentsmd --write AGENTS.md model.aon\n```\n\nSplices a derived stanza — the pin, the root keys, the shape, and the\ncommands spelled with paths that exist — between two markers, and\nleaves the rest of the file alone. Re-run it in the commit that\nchanges the model.\n\n## When something refuses\n\nRead the code in the brackets, then look it up:\n\n```\naontu explain no_scalar_unify\n```\n\nEvery finding carries a `code`, a `class`, a `path` and the **two\nsites** that disagree — the value and the constraint it failed, each\nwith its file and line. The class says what kind of answer it is:\n`conflict` (two things cannot both hold), `incomplete` (nothing is\nwrong yet, something is missing), `parse`, `reference`, `budget`.\n\nFull index: [`error-codes.md`](error-codes.md). The language on one\npage: [`grammar-card.md`](grammar-card.md). The worked ladder from\nplain JSON upward: [`examples.md`](examples.md).\n"
20
+ "text": "# What do you want to do?\n\nThe verb for a job, found by the word you arrived with. aontu's own\nvocabulary is on the right; yours is probably on the left.\n\n## Start from a document that works\n\nNothing written yet? Do not invent the first document, edit one:\n\n```\naontu init # into the working directory\naontu init model/ # or into a named one\nsh check.sh # the four questions, on what was just written\n```\n\n`init` writes `model.aon` (an entity map constrained with `&:`),\n`data.aon` (an instance of it that holds) and `check.sh` (the four\nchecks to run after every edit). It never overwrites: if any\nof the three already stands there, it refuses and writes none of them.\n\n## Describe a domain\n\nAn **ontology**, a **schema**, a **data model**, a **contract** — in\naontu these are all one thing: a document. Write the entities as a\n**map keyed by name**, and say what every entry must satisfy with the\n`&:` template:\n\n```\nentity: {\n &: {\n table: string\n fields: { &: { type: string, required: *false | boolean } }\n }\n planet: { table: \"planets\", fields: { name: { type: \"string\" } } }\n moon: { table: \"moons\", fields: { name: { type: \"string\" } } }\n}\n```\n\n`&:` is the construct to reach for first, and the one most easily\nmissed. It meets **every** key of the map it sits in. A quoted `\"*\"`\nis not a wildcard — it is a key named `*`, and a schema written that\nway constrains nothing while still reporting `valid`.\n\nPrefer a named map to a list unless the order is a fact (a migration\nsequence, a rule table tried in order). A key is an address, a name\nother parts can refer to, and a diff that shows one insertion instead\nof every following element renumbered.\n\n## Check data against a model\n\n```\naontu vet model.aon data.aon\n```\n\nExit `0` valid, `1` a contradiction, `3` incomplete (nothing\ncontradicts, but the model is not yet satisfied), `4` the model does\nnot stand up on its own. `--format json` for the machine-readable\nreport, `--closed` to refuse keys the model does not declare,\n`--max-errors <n>` to cap the list.\n\n**Make the check prove it checked something.** A check that examined\nnothing answers exactly like one that passed:\n\n```\naontu vet --coverage model.aon data.aon # what did it examine?\naontu vet --strict-coverage model.aon data.aon # exit 1 if nothing\n```\n\n`--coverage` reports how many data leaves a declaration constrained,\nthe data paths none did, and the declarations no data met.\n`--strict-coverage` exits 1 when the answer is nothing. The usual\ncause is the `\"*\"` mistake above: reach for `&:`.\n\n## Check the model is coherent with itself\n\n```\naontu relations model.aon # declared entity edges: targets resolve, no cycles\naontu reaches planet moon model.aon # does one entity reach another, at any remove?\naontu trim --check model.aon # entries whose removal changes nothing\n```\n\n## Generate code from a model, and hold it\n\n```\naontu render gen.aon build # write the files below build/\naontu render --check gen.aon build # write nothing; exit 1 when build/ has drifted\naontu render gen/ build # every generator directly in gen/, as one run\naontu model get out gen.aon # the component tree: files, and the lines in them\naontu trace gen.aon # what rule wrote each line\n```\n\nA generator answers a **component tree** -- `file(name, [lines])` and\nits neighbours -- which `aontu render` hands to jostraca, the generator\nruntime, to write. A tree that is one file is written to the path\nitself, and a folder of generators is one run. `--check` compares with\nwhat is committed: the model is the\ntruth, the code is the claim, and drift is a finding. `aontu trace`\nanswers the other direction: for a line of the output, the model node\nand the rule behind it.\n\n## Ask what a model says, and why\n\n```\naontu model get $.entity.planet.table model.aon\naontu model why $.entity.planet.table model.aon\naontu model get $.entity --keys model.aon\naontu model get $.entity --types model.aon\n```\n\n`why` names **every** contribution to a value, with the file and line\neach was written on. It is the first thing to run when a value is not\nwhat you expected, and the second thing to run when `vet` refuses.\n\n## Change a value without editing the file\n\n```\naontu model set '$.entity.planet.table=planet_v2' --entry model.aon --overlay local.aon\n```\n\nThe change is checked before it is written; a change that contradicts\na pinned value is refused, and the file is left alone.\n\n## Gate a change to the model itself\n\n```\naontu subsume old.aon new.aon # does the general admit every specific?\naontu breaking --against git#HEAD~1 model.aon\naontu hash model.aon # a pin that survives reformatting\n```\n\n## Hand a model to another agent\n\n```\naontu agentsmd --write AGENTS.md model.aon\n```\n\nSplices a derived stanza — the pin, the root keys, the shape, and the\ncommands spelled with paths that exist — between two markers, and\nleaves the rest of the file alone. Re-run it in the commit that\nchanges the model.\n\n## When something refuses\n\nRead the code in the brackets, then look it up:\n\n```\naontu explain no_scalar_unify\n```\n\nEvery finding carries a `code`, a `class`, a `path` and the **two\nsites** that disagree — the value and the constraint it failed, each\nwith its file and line. The class says what kind of answer it is:\n`conflict` (two things cannot both hold), `incomplete` (nothing is\nwrong yet, something is missing), `parse`, `reference`, `budget`.\n\nFull index: [`error-codes.md`](error-codes.md). The language on one\npage: [`grammar-card.md`](grammar-card.md). The worked ladder from\nplain JSON upward: [`examples.md`](examples.md).\n"
21
21
  },
22
22
  {
23
23
  "topic": "language",