aontu 0.60.0 → 0.62.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 (113) hide show
  1. package/README.md +3 -3
  2. package/dist/agentsmd.d.ts +1 -0
  3. package/dist/agentsmd.js +7 -1
  4. package/dist/agentsmd.js.map +1 -1
  5. package/dist/allow.d.ts +23 -0
  6. package/dist/allow.js +230 -0
  7. package/dist/allow.js.map +1 -0
  8. package/dist/aontu.d.ts +4 -2
  9. package/dist/aontu.js +4 -2
  10. package/dist/aontu.js.map +1 -1
  11. package/dist/cli.d.ts +10 -1
  12. package/dist/cli.js +1000 -47
  13. package/dist/cli.js.map +1 -1
  14. package/dist/format.d.ts +1 -0
  15. package/dist/format.js +154 -12
  16. package/dist/format.js.map +1 -1
  17. package/dist/helpdoc.d.ts +16 -0
  18. package/dist/helpdoc.js +59 -0
  19. package/dist/helpdoc.js.map +1 -0
  20. package/dist/hints.js +15 -2
  21. package/dist/hints.js.map +1 -1
  22. package/dist/lang.js +93 -40
  23. package/dist/lang.js.map +1 -1
  24. package/dist/lower.d.ts +3 -0
  25. package/dist/lower.js +3 -0
  26. package/dist/lower.js.map +1 -1
  27. package/dist/lsp.d.ts +1 -1
  28. package/dist/lsp.js +1 -1
  29. package/dist/lsp.js.map +1 -1
  30. package/dist/relation.d.ts +2 -0
  31. package/dist/relation.js +7 -1
  32. package/dist/relation.js.map +1 -1
  33. package/dist/render.js +26 -21
  34. package/dist/render.js.map +1 -1
  35. package/dist/sigdecl.js +1 -1
  36. package/dist/sigdecl.js.map +1 -1
  37. package/dist/std.js +112 -69
  38. package/dist/std.js.map +1 -1
  39. package/dist/template.d.ts +2 -1
  40. package/dist/template.js +51 -15
  41. package/dist/template.js.map +1 -1
  42. package/dist/tsconfig.tsbuildinfo +1 -1
  43. package/dist/val/AggFuncVal.js +2 -2
  44. package/dist/val/AggFuncVal.js.map +1 -1
  45. package/dist/val/CmpFuncVal.d.ts +20 -0
  46. package/dist/val/CmpFuncVal.js +245 -0
  47. package/dist/val/CmpFuncVal.js.map +1 -0
  48. package/dist/val/EachFuncVal.d.ts +1 -2
  49. package/dist/val/EachFuncVal.js +15 -29
  50. package/dist/val/EachFuncVal.js.map +1 -1
  51. package/dist/val/FormFuncVal.js.map +1 -1
  52. package/dist/val/LowerFuncVal.js +17 -1
  53. package/dist/val/LowerFuncVal.js.map +1 -1
  54. package/dist/val/NamerFuncVal.d.ts +12 -0
  55. package/dist/val/NamerFuncVal.js +176 -0
  56. package/dist/val/NamerFuncVal.js.map +1 -0
  57. package/dist/val/NilVal.js +29 -3
  58. package/dist/val/NilVal.js.map +1 -1
  59. package/dist/val/NomFuncVal.d.ts +12 -0
  60. package/dist/val/NomFuncVal.js +187 -0
  61. package/dist/val/NomFuncVal.js.map +1 -0
  62. package/dist/val/PackFuncVal.js +3 -3
  63. package/dist/val/PackFuncVal.js.map +1 -1
  64. package/dist/val/RefVal.js +1 -1
  65. package/dist/val/TranslateFuncVal.d.ts +12 -0
  66. package/dist/val/TranslateFuncVal.js +101 -0
  67. package/dist/val/TranslateFuncVal.js.map +1 -0
  68. package/dist/val/UpperFuncVal.js +17 -1
  69. package/dist/val/UpperFuncVal.js.map +1 -1
  70. package/dist/val/caserange.d.ts +3 -0
  71. package/dist/val/caserange.js +110 -0
  72. package/dist/val/caserange.js.map +1 -0
  73. package/dist/vet.d.ts +12 -0
  74. package/dist/vet.js +209 -1
  75. package/dist/vet.js.map +1 -1
  76. package/grammar/aontu.abnf +1 -1
  77. package/grammar/aontu.gbnf +1 -1
  78. package/grammar/aontu.lark +1 -1
  79. package/grammar/aontu.tmLanguage.json +1 -1
  80. package/package.json +1 -1
  81. package/skill/SKILL.md +8 -0
  82. package/skill/init/check.sh +28 -0
  83. package/skill/init/data.aon +12 -0
  84. package/skill/init/model.aon +19 -0
  85. package/skill/tasks.md +151 -0
  86. package/src/agentsmd.ts +13 -2
  87. package/src/allow.ts +316 -0
  88. package/src/aontu.ts +10 -1
  89. package/src/cli.ts +1131 -53
  90. package/src/format.ts +191 -14
  91. package/src/helpdoc.ts +77 -0
  92. package/src/hints.ts +16 -2
  93. package/src/lang.ts +98 -42
  94. package/src/lower.ts +3 -3
  95. package/src/lsp.ts +1 -1
  96. package/src/relation.ts +21 -1
  97. package/src/render.ts +26 -21
  98. package/src/sigdecl.ts +1 -1
  99. package/src/std.ts +112 -69
  100. package/src/template.ts +55 -15
  101. package/src/val/AggFuncVal.ts +2 -2
  102. package/src/val/CmpFuncVal.ts +411 -0
  103. package/src/val/EachFuncVal.ts +49 -50
  104. package/src/val/LowerFuncVal.ts +18 -1
  105. package/src/val/NilVal.ts +29 -3
  106. package/src/val/NomFuncVal.ts +287 -0
  107. package/src/val/PackFuncVal.ts +3 -3
  108. package/src/val/RefVal.ts +1 -1
  109. package/src/val/TranslateFuncVal.ts +182 -0
  110. package/src/val/UpperFuncVal.ts +18 -1
  111. package/src/val/caserange.ts +115 -0
  112. package/src/vet.ts +287 -1
  113. package/src/val/FormFuncVal.ts +0 -119
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env sh
2
+ # check.sh --- the four questions to ask of a model. Run it after
3
+ # every edit to model.aon or data.aon.
4
+ #
5
+ # `aontu help tasks` maps a job to a verb; `aontu explain <code>` says
6
+ # what a refusal means; `aontu help language` is the whole grammar on
7
+ # one page. None of them needs a network.
8
+ set -eu
9
+
10
+ AONTU="${AONTU:-aontu}"
11
+ cd "$(dirname "$0")"
12
+
13
+ # 1. Does the data satisfy the truth -- and did the check examine
14
+ # anything? --strict-coverage exits 1 on a check that constrained no
15
+ # value, which is the failure a passing gate hides.
16
+ $AONTU vet --strict-coverage model.aon data.aon
17
+
18
+ # 2. What does it say at a path?
19
+ $AONTU get '$.entity.planet.table' data.aon
20
+
21
+ # 3. Why does it say that? Every contribution, with the line it is on.
22
+ $AONTU why '$.entity.planet.table' data.aon
23
+
24
+ # 4. A pin for the truth: it survives reformatting and moves on any
25
+ # change of meaning.
26
+ $AONTU hash model.aon
27
+
28
+ echo "ok --- model.aon and data.aon agree"
@@ -0,0 +1,12 @@
1
+ # data.aon --- an instance of the truth in model.aon. Edit it and run
2
+ # check.sh: every change is checked against the model, and a change the
3
+ # model refuses is named with the line it is on.
4
+ entity: {
5
+ planet: {
6
+ table: "planets"
7
+ fields: {
8
+ name: { type: "string", required: true }
9
+ diameter: { type: "integer" }
10
+ }
11
+ }
12
+ }
@@ -0,0 +1,19 @@
1
+ # model.aon --- the truth. `aontu vet model.aon data.aon` asks whether
2
+ # a document satisfies it.
3
+ #
4
+ # `&:` is the construct to learn first: it meets EVERY key of the map
5
+ # it sits in, so one statement constrains every entity. A quoted "*" is
6
+ # not a wildcard -- it is a key named `*`, it meets nothing, and a
7
+ # schema written that way reports `valid` over data that violates it.
8
+ # `aontu vet --strict-coverage` refuses to pass such a check.
9
+ entity: {
10
+ &: {
11
+ table: string & re("^[a-z][a-z0-9_]*$")
12
+ fields: {
13
+ &: {
14
+ type: "string" | "integer" | "boolean"
15
+ required: *false | boolean
16
+ }
17
+ }
18
+ }
19
+ }
package/skill/tasks.md ADDED
@@ -0,0 +1,151 @@
1
+ # What do you want to do?
2
+
3
+ The verb for a job, found by the word you arrived with. aontu's own
4
+ vocabulary is on the right; yours is probably on the left.
5
+
6
+ ## Start from a document that works
7
+
8
+ Nothing written yet? Do not invent the first document, edit one:
9
+
10
+ ```
11
+ aontu init # into the working directory
12
+ aontu init model/ # or into a named one
13
+ sh check.sh # the four questions, on what was just written
14
+ ```
15
+
16
+ `init` writes `model.aon` (an entity map constrained with `&:`),
17
+ `data.aon` (an instance of it that holds) and `check.sh` (the four
18
+ checks to run after every edit). It never overwrites: if any
19
+ of the three already stands there, it refuses and writes none of them.
20
+
21
+ ## Describe a domain
22
+
23
+ An **ontology**, a **schema**, a **data model**, a **contract** — in
24
+ aontu these are all one thing: a document. Write the entities as a
25
+ **map keyed by name**, and say what every entry must satisfy with the
26
+ `&:` template:
27
+
28
+ ```
29
+ entity: {
30
+ &: {
31
+ table: string
32
+ fields: { &: { type: string, required: *false | boolean } }
33
+ }
34
+ planet: { table: "planets", fields: { name: { type: "string" } } }
35
+ moon: { table: "moons", fields: { name: { type: "string" } } }
36
+ }
37
+ ```
38
+
39
+ `&:` is the construct to reach for first, and the one most easily
40
+ missed. It meets **every** key of the map it sits in. A quoted `"*"`
41
+ is not a wildcard — it is a key named `*`, and a schema written that
42
+ way constrains nothing while still reporting `valid`.
43
+
44
+ Prefer a named map to a list unless the order is a fact (a migration
45
+ sequence, a rule table tried in order). A key is an address, a name
46
+ other parts can refer to, and a diff that shows one insertion instead
47
+ of every following element renumbered.
48
+
49
+ ## Check data against a model
50
+
51
+ ```
52
+ aontu vet model.aon data.aon
53
+ ```
54
+
55
+ Exit `0` valid, `1` a contradiction, `3` incomplete (nothing
56
+ contradicts, but the model is not yet satisfied), `4` the model does
57
+ not stand up on its own. `--format json` for the machine-readable
58
+ report, `--closed` to refuse keys the model does not declare,
59
+ `--max-errors <n>` to cap the list.
60
+
61
+ **Make the check prove it checked something.** A check that examined
62
+ nothing answers exactly like one that passed:
63
+
64
+ ```
65
+ aontu vet --coverage model.aon data.aon # what did it examine?
66
+ aontu vet --strict-coverage model.aon data.aon # exit 1 if nothing
67
+ ```
68
+
69
+ `--coverage` reports how many data leaves a declaration constrained,
70
+ the data paths none did, and the declarations no data met.
71
+ `--strict-coverage` exits 1 when the answer is nothing. The usual
72
+ cause is the `"*"` mistake above: reach for `&:`.
73
+
74
+ ## Check the model is coherent with itself
75
+
76
+ ```
77
+ aontu relations model.aon # declared entity edges: targets resolve, no cycles
78
+ aontu reaches planet moon model.aon # does one entity reach another, at any remove?
79
+ aontu trim --check model.aon # entries whose removal changes nothing
80
+ ```
81
+
82
+ ## Check code against a model
83
+
84
+ ```
85
+ aontu render --check src/ --profile go.aon model.aon
86
+ ```
87
+
88
+ `render` writes code from the model; `--check` writes nothing and
89
+ lists what on disk differs from what the model implies. That is the
90
+ gate: the model is the truth, the code is the claim, and drift is a
91
+ finding. `--coverage` reports the other direction — model paths no
92
+ output consumed, and rendered declarations no rule produced.
93
+
94
+ ## Ask what a model says, and why
95
+
96
+ ```
97
+ aontu get $.entity.planet.table model.aon
98
+ aontu why $.entity.planet.table model.aon
99
+ aontu get $.entity --keys model.aon
100
+ aontu get $.entity --types model.aon
101
+ ```
102
+
103
+ `why` names **every** contribution to a value, with the file and line
104
+ each was written on. It is the first thing to run when a value is not
105
+ what you expected, and the second thing to run when `vet` refuses.
106
+
107
+ ## Change a value without editing the file
108
+
109
+ ```
110
+ aontu set '$.entity.planet.table=planet_v2' --entry model.aon --overlay local.aon
111
+ ```
112
+
113
+ The change is checked before it is written; a change that contradicts
114
+ a pinned value is refused, and the file is left alone.
115
+
116
+ ## Gate a change to the model itself
117
+
118
+ ```
119
+ aontu subsume old.aon new.aon # does the general admit every specific?
120
+ aontu breaking --against git#HEAD~1 model.aon
121
+ aontu hash model.aon # a pin that survives reformatting
122
+ ```
123
+
124
+ ## Hand a model to another agent
125
+
126
+ ```
127
+ aontu agentsmd --write AGENTS.md model.aon
128
+ ```
129
+
130
+ Splices a derived stanza — the pin, the root keys, the shape, and the
131
+ commands spelled with paths that exist — between two markers, and
132
+ leaves the rest of the file alone. Re-run it in the commit that
133
+ changes the model.
134
+
135
+ ## When something refuses
136
+
137
+ Read the code in the brackets, then look it up:
138
+
139
+ ```
140
+ aontu explain no_scalar_unify
141
+ ```
142
+
143
+ Every finding carries a `code`, a `class`, a `path` and the **two
144
+ sites** that disagree — the value and the constraint it failed, each
145
+ with its file and line. The class says what kind of answer it is:
146
+ `conflict` (two things cannot both hold), `incomplete` (nothing is
147
+ wrong yet, something is missing), `parse`, `reference`, `budget`.
148
+
149
+ Full index: [`error-codes.md`](error-codes.md). The language on one
150
+ page: [`grammar-card.md`](grammar-card.md). The worked ladder from
151
+ plain JSON upward: [`examples.md`](examples.md).
package/src/agentsmd.ts CHANGED
@@ -34,6 +34,12 @@ export type AgentsMdReport = {
34
34
  }
35
35
 
36
36
  export type AgentsMdOptions = {
37
+ // How deep the SHAPE line projects, default 2 (G11 phase 7). Two
38
+ // levels name the root keys and say `top` under them, which tells an
39
+ // agent what the document is ABOUT and nothing it can act on; a
40
+ // caller that wants the fields asks for them. The default is
41
+ // unchanged, because the stanza is spliced into a file people read.
42
+ depth?: number
37
43
  // The name the stanza should call the document. The engine never
38
44
  // reads a file; the CLI passes what the author typed.
39
45
  name?: string
@@ -70,8 +76,10 @@ export function agentsMd(
70
76
  // arrive through a `--text-ext` include listed those keys and then
71
77
  // reported an EMPTY shape, because the read the shape came from
72
78
  // refused the include the read above it had just honoured.
73
- const shape = get(src, '$',
74
- { view: 'types', depth: 2, path: options.path, ...includeOpts(options) })
79
+ const shape = get(src, '$', {
80
+ view: 'types', depth: options.depth ?? 2,
81
+ path: options.path, ...includeOpts(options),
82
+ })
75
83
 
76
84
  // A REAL path, so the example command works as written: the first
77
85
  // root key when there is one, the root itself when there is not.
@@ -107,6 +115,9 @@ export function agentsMd(
107
115
  '# change it without editing it',
108
116
  'aontu set ' + example + '=<value> --entry ' + name +
109
117
  ' --overlay overlay.aon',
118
+ '',
119
+ '# the language itself, offline: the whole grammar on one page',
120
+ 'aontu help language',
110
121
  '```',
111
122
  '',
112
123
  'Regenerate this section with `aontu agentsmd ' + name + '`.',
package/src/allow.ts ADDED
@@ -0,0 +1,316 @@
1
+ /* Copyright (c) 2025 Richard Rodger, MIT License */
2
+
3
+ // THE ROLE GATE: may an agent operating under a ROLE modify a SUBTREE?
4
+ //
5
+ // An agent about to change a model (`aontu set`, an overlay, a rewrite)
6
+ // asks first, and the answer comes from a ROLE MODEL that is itself an
7
+ // aontu document: one entry per role, each naming the subtrees the
8
+ // role may modify and, optionally, the ones it may not. Roles are
9
+ // arbitrary identifier strings -- `admin`, `dev`, `product`, `qa` --
10
+ // and the model is whatever its author writes, so the rules of the
11
+ // language (spreads, references, includes, `close()`) compose role
12
+ // models the way they compose everything else.
13
+ //
14
+ // The shape of a role is aontu too. It is conjoined with the model at
15
+ // evaluation, as a spread template over the roles map:
16
+ //
17
+ // roles: { &: { allow: [&: Entry] deny?: [&: Entry] } }
18
+ // Entry = string & re("^[$]") & re("[^.]$")
19
+ //
20
+ // so a malformed role -- `allow: "$.a"`, `deny: [1]`, an entry that is
21
+ // empty or does not start at the root or ends in a dot, a roles map
22
+ // that is a number -- is refused by the engine with the engine's own
23
+ // code and site, and this module never invents a finding shape of its
24
+ // own. The shape is a VALUE the model meets, not text appended to it:
25
+ // a model whose last line is an unclosed map or a dangling key would
26
+ // swallow appended text, and the shape would then apply to nothing.
27
+ // A role with no `allow` list allows nothing, because the template's
28
+ // empty list is what the tree holds for it.
29
+ //
30
+ // The lists are read from the WRITTEN tree, not from the generated
31
+ // document: a `hide()` mark keeps a list out of the output, and a gate
32
+ // that read the output would let a hidden `deny` vanish -- the wrong
33
+ // direction to be wrong in. Each entry must still be one concrete
34
+ // string: a kind (`string`) or anything else that does not generate is
35
+ // refused with the engine's `no_gen`.
36
+ //
37
+ // The rule is deliberately small, and it errs towards refusal:
38
+ //
39
+ // - A path is ALLOWED when some `allow` entry is an ancestor of it or
40
+ // equal to it. Being allowed `$.services` allows `$.services.auth`
41
+ // and `$.services.auth.replicas`; it does not allow `$` -- a change
42
+ // at the root reaches every sibling too.
43
+ // - A path is REFUSED when any `deny` entry INTERSECTS it: an
44
+ // ancestor, itself, or a descendant. Denied `$.services.*.tier`
45
+ // refuses `$.services.auth.tier` (the denied node), and it refuses
46
+ // `$.services.auth` and `$.services` too, because a change at
47
+ // either could rewrite the tier. Deny wins over allow whatever the
48
+ // order the entries were written in.
49
+ // - `*` in an entry matches exactly one segment, any key. It is the
50
+ // only pattern character; everything else is a key or a list index
51
+ // compared for equality, as a reference compares them.
52
+ // - A role is ONE KEY of the roles map, looked up as written, and a
53
+ // role the map does not declare may modify nothing.
54
+ //
55
+ // The answer names the entry that decided it, as a path INTO THE ROLE
56
+ // MODEL (`$.roles.dev.deny.0`), so `aontu why` can say who wrote the
57
+ // rule and where.
58
+
59
+ import { Aontu } from './aontu'
60
+ import { ConjunctVal } from './val/ConjunctVal'
61
+ import { anchorAt } from './vet'
62
+ import type { VetFinding } from './vet'
63
+ import { evalFailure, nearestKey, pathParts } from './query'
64
+ import { includeOpts } from './utility'
65
+ import type { IncludeOptions } from './utility'
66
+
67
+
68
+ // Where the roles map lives when the caller does not say.
69
+ export const ALLOW_AT = '$.roles'
70
+
71
+
72
+ export type AllowVerdict = 'allowed' | 'refused' | 'error'
73
+
74
+
75
+ // What decided one path: the entry that covers it, the entry that
76
+ // intersects it, nothing at all, or a role the model does not declare.
77
+ export type AllowReason = 'allow' | 'deny' | 'uncovered' | 'no_role'
78
+
79
+
80
+ export type AllowDecision = {
81
+ path: string // the asked path, normalised: `$.a.b`
82
+ allowed: boolean
83
+ reason: AllowReason
84
+ by?: string // the deciding entry's path in the role model
85
+ pattern?: string // that entry's text, as the author wrote it
86
+ }
87
+
88
+
89
+ export type AllowReport = {
90
+ verdict: AllowVerdict
91
+ role: string
92
+ paths: AllowDecision[]
93
+ // G2's finding shape, as every verb reports: empty when the verdict
94
+ // is `allowed`, the engine's own failure when it is `error`, and
95
+ // the `no_path` of an undeclared role beside the refusals.
96
+ findings: VetFinding[]
97
+ }
98
+
99
+
100
+ export type AllowOptions = IncludeOptions & {
101
+ // Where the role model came from: relative loads resolve against
102
+ // its directory, and a finding's site names it.
103
+ path?: string
104
+ // The path of the roles map inside the model (default `$.roles`).
105
+ at?: string
106
+ }
107
+
108
+
109
+ // One entry: a string that starts at the root and does not end in a
110
+ // dot. `re()` refuses a nested quantifier, so the two conditions are
111
+ // two patterns rather than one grammar; an empty segment in the middle
112
+ // is harmless, because the path split drops it as a reference does.
113
+ const ENTRY = 'string & re("^[$]") & re("[^.]$")'
114
+
115
+ // The shape every role must satisfy, in the language: a list of
116
+ // subtree entries to allow, and optionally one to deny.
117
+ const ROLE_SHAPE = `{ allow: [&: ${ENTRY}] deny?: [&: ${ENTRY}] }`
118
+
119
+
120
+ // The shape document: a spread over the roles map at `at`, or -- when
121
+ // the roles map IS the document -- a top-level spread. Keys are quoted
122
+ // the way `aontu set` quotes an overlay line, so a segment may be a
123
+ // word the grammar spells otherwise.
124
+ function shapeSource(at: string): string {
125
+ const keys = pathParts(at).map((p) => JSON.stringify(p))
126
+ return 0 === keys.length
127
+ ? '&: ' + ROLE_SHAPE
128
+ : keys.join(': ') + ': { &: ' + ROLE_SHAPE + ' }'
129
+ }
130
+
131
+
132
+ // `$.a.b` for a segment list, `$` for none: the spelling every report
133
+ // uses for a path, whatever the caller wrote.
134
+ function pathText(parts: string[]): string {
135
+ return '$' + (0 < parts.length ? '.' + parts.join('.') : '')
136
+ }
137
+
138
+
139
+ // The finding shape `get` reports with, deliberately: the gate invents
140
+ // no error format of its own.
141
+ function finding(
142
+ code: string, path: string, message: string, note?: string): VetFinding {
143
+ return {
144
+ code,
145
+ class: 'reference',
146
+ severity: 'error',
147
+ path,
148
+ message,
149
+ sites: [],
150
+ ...(null == note ? {} : { note }),
151
+ }
152
+ }
153
+
154
+
155
+ type Entry = {
156
+ parts: string[]
157
+ text: string
158
+ by: string
159
+ }
160
+
161
+
162
+ // The entries of one list of the role, read from the tree. A concrete
163
+ // string is taken as written, hidden or not; anything else is asked
164
+ // to generate, which is where a kind or a hidden kind fails with the
165
+ // engine's own code, and where a preference answers with its default.
166
+ function readEntries(
167
+ list: any, by: string, ctx: any,
168
+ ): { entries: Entry[], finding?: VetFinding } {
169
+ const entries: Entry[] = []
170
+ for (let i = 0; i < list.peg.length; i++) {
171
+ const el: any = list.peg[i]
172
+ const before = ctx.err.length
173
+ const text: string = true === el.isString ? el.peg : el.gen(ctx)
174
+ if (before < ctx.err.length) {
175
+ const err: any = ctx.err[before]
176
+ return { entries, finding: finding(err.why, `${by}.${i}`, err.msg) }
177
+ }
178
+ entries.push({ parts: pathParts(text), text, by: `${by}.${i}` })
179
+ }
180
+ return { entries }
181
+ }
182
+
183
+
184
+ // One segment of an entry against one segment of a path: `*` matches
185
+ // any key, anything else matches itself.
186
+ function segmentMatches(pattern: string, segment: string): boolean {
187
+ return '*' === pattern || pattern === segment
188
+ }
189
+
190
+
191
+ // The two subtrees share a node: one is an ancestor of the other, or
192
+ // they are the same. Checked over the shorter of the two, because the
193
+ // longer one only says where inside the shared subtree it goes on.
194
+ function intersects(entry: string[], path: string[]): boolean {
195
+ const n = Math.min(entry.length, path.length)
196
+ for (let i = 0; i < n; i++) {
197
+ if (!segmentMatches(entry[i], path[i])) {
198
+ return false
199
+ }
200
+ }
201
+ return true
202
+ }
203
+
204
+
205
+ // The entry is an ancestor of the path, or the path itself. A longer
206
+ // entry names something INSIDE the asked subtree, and a change to the
207
+ // subtree reaches its siblings, so it does not cover.
208
+ function covers(entry: string[], path: string[]): boolean {
209
+ return entry.length <= path.length && intersects(entry, path)
210
+ }
211
+
212
+
213
+ function decide(asked: string, allows: Entry[], denies: Entry[]): AllowDecision {
214
+ const parts = pathParts(asked)
215
+ const path = pathText(parts)
216
+
217
+ // Deny first, and any intersection refuses: an entry above the path
218
+ // forbids the whole subtree it is in, and one below it forbids the
219
+ // change that would rewrite it from above.
220
+ for (const d of denies) {
221
+ if (intersects(d.parts, parts)) {
222
+ return { path, allowed: false, reason: 'deny', by: d.by, pattern: d.text }
223
+ }
224
+ }
225
+ for (const a of allows) {
226
+ if (covers(a.parts, parts)) {
227
+ return { path, allowed: true, reason: 'allow', by: a.by, pattern: a.text }
228
+ }
229
+ }
230
+ return { path, allowed: false, reason: 'uncovered' }
231
+ }
232
+
233
+
234
+ function errorReport(role: string, f: VetFinding): AllowReport {
235
+ return { verdict: 'error', role, paths: [], findings: [f] }
236
+ }
237
+
238
+
239
+ // Evaluate the role model, select the role, and decide every path.
240
+ export function allow(
241
+ src: string, role: string, paths: string[], opts?: AllowOptions,
242
+ ): AllowReport {
243
+ const options = opts ?? {}
244
+ const at = pathText(pathParts(options.at ?? ALLOW_AT))
245
+
246
+ const aontu = new Aontu(includeOpts(options))
247
+ const ctx = aontu.ctx({ collect: true })
248
+ const parseOpts = null == options.path ? undefined : { path: options.path }
249
+
250
+ // The model MEETS the shape as data meets a schema under vet: both
251
+ // parsed, conjoined, and unified ONCE. A parsed tree is single-use,
252
+ // and a model evaluated on its own and then met again has already
253
+ // resolved its references against itself, so a registry written
254
+ // `roles: close({ &: $.Role ... })` would fail its second pass with
255
+ // a `$.Role` it cannot find. The shape is parsed FIRST: the context
256
+ // takes the last parsed document as its root and as the text an
257
+ // error frame excerpts, and both must be the model's.
258
+ const shape = aontu.parse(shapeSource(at), undefined, ctx)
259
+ const model = aontu.parse(src, parseOpts, ctx)
260
+ if (0 < ctx.err.length) {
261
+ return errorReport(role, evalFailure(ctx))
262
+ }
263
+ const root: any = aontu.unify(
264
+ new ConjunctVal({ peg: [model, shape] }, ctx), undefined, ctx)
265
+ if (0 < ctx.err.length || true === root.isNil) {
266
+ return errorReport(role, evalFailure(ctx))
267
+ }
268
+
269
+ // The shape made the roles map exist, so what can be missing is the
270
+ // ROLE -- and an undeclared role may modify nothing. That is the
271
+ // question's answer (refused), not a broken model (error), and the
272
+ // finding carries the nearest declared name. The role is one key,
273
+ // looked up as written: not a path, so a name may hold a dot, and
274
+ // an own key only, so a name the prototype has is not a role.
275
+ const roles: any = anchorAt(root, at)
276
+ const atRole = `${at}.${role}`
277
+ if (!Object.prototype.hasOwnProperty.call(roles.peg, role)) {
278
+ const near = nearestKey(role, Object.keys(roles.peg))
279
+ return {
280
+ verdict: 'refused',
281
+ role,
282
+ paths: paths.map((p) => ({
283
+ path: pathText(pathParts(p)), allowed: false, reason: 'no_role',
284
+ })),
285
+ findings: [finding(
286
+ 'no_path',
287
+ atRole,
288
+ `The role ${role} is not declared at ${at} in this document.`,
289
+ null == near ? undefined : `did you mean ${near}?`)],
290
+ }
291
+ }
292
+ const node: any = roles.peg[role]
293
+
294
+ const allows = readEntries(node.peg.allow, `${atRole}.allow`, ctx)
295
+ if (null != allows.finding) {
296
+ return errorReport(role, allows.finding)
297
+ }
298
+ const denies = readEntries(node.peg.deny, `${atRole}.deny`, ctx)
299
+ if (null != denies.finding) {
300
+ return errorReport(role, denies.finding)
301
+ }
302
+
303
+ const decisions = paths.map((p) => decide(p, allows.entries, denies.entries))
304
+
305
+ // Nothing asked is nothing allowed: a gate that answered `allowed`
306
+ // to an empty question would let a caller that dropped its
307
+ // arguments through.
308
+ const allowed = 0 < decisions.length && decisions.every((d) => d.allowed)
309
+
310
+ return {
311
+ verdict: allowed ? 'allowed' : 'refused',
312
+ role,
313
+ paths: decisions,
314
+ findings: [],
315
+ }
316
+ }
package/src/aontu.ts CHANGED
@@ -22,6 +22,10 @@ import { get, why } from './query'
22
22
  import { patch } from './patch'
23
23
  import { diff } from './diff'
24
24
  import { agentsMd } from './agentsmd'
25
+ import { allow } from './allow'
26
+ export type {
27
+ AllowDecision, AllowOptions, AllowReason, AllowReport, AllowVerdict,
28
+ } from './allow'
25
29
  import { graphOf } from './graph'
26
30
  import { relationCheck, relationErrors } from './relation'
27
31
  import { view, viewSet, viewTree } from './view'
@@ -38,7 +42,7 @@ export type { LintFinding, FormatReport, FormatOptions } from './format'
38
42
  // Kept in step with package.json by the `version` npm lifecycle script,
39
43
  // which runs on `npm version` / `npm run repo-bump`. version.test.ts
40
44
  // fails if the two ever drift.
41
- const VERSION = '0.60.0'
45
+ const VERSION = '0.62.0'
42
46
 
43
47
 
44
48
  // A module file's VALUE, as far as it goes. COLLECTED, not raised: a
@@ -475,6 +479,11 @@ export {
475
479
  patch,
476
480
  diff,
477
481
  agentsMd,
482
+
483
+ // The role gate (docs/design/ALLOW.0.md): may a role modify a
484
+ // subtree, by a role model that is itself an aontu document. The
485
+ // question an agent asks before `set`.
486
+ allow,
478
487
  graphOf,
479
488
  relationCheck,
480
489
  view,