@json-layout/core 2.8.2 → 2.9.1
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.
- package/package.json +4 -2
- package/src/compile/index.js +3 -1
- package/src/compile/skeleton-node.js +46 -5
- package/src/compile/types.ts +1 -0
- package/src/compile/utils/resolve-refs.js +5 -8
- package/src/compile/utils/x-i18n.js +15 -6
- package/src/state/index.js +18 -1
- package/src/state/state-node.js +11 -2
- package/src/state/utils/urls.js +2 -0
- package/src/utils/json-pointer.js +29 -0
- package/src/webmcp/README.md +144 -0
- package/src/webmcp/index.js +88 -82
- package/src/webmcp/project.js +542 -111
- package/src/webmcp/resolve.js +37 -1
- package/src/webmcp/schema.js +169 -0
- package/src/webmcp/suggestions-store.js +121 -0
- package/src/webmcp/tools/describe-state.js +20 -64
- package/src/webmcp/tools/edit-array.js +51 -28
- package/src/webmcp/tools/fill-form-skill.js +17 -41
- package/src/webmcp/tools/get-data.js +66 -13
- package/src/webmcp/tools/get-field-suggestions.js +12 -23
- package/src/webmcp/tools/set-data.js +79 -28
- package/src/webmcp/tools/set-field-value.js +88 -39
- package/src/webmcp/variants-memo.js +53 -0
- package/types/compile/index.d.ts.map +1 -1
- package/types/compile/skeleton-node.d.ts +9 -2
- package/types/compile/skeleton-node.d.ts.map +1 -1
- package/types/compile/types.d.ts +1 -0
- package/types/compile/types.d.ts.map +1 -1
- package/types/compile/utils/resolve-refs.d.ts.map +1 -1
- package/types/compile/utils/x-i18n.d.ts +1 -1
- package/types/compile/utils/x-i18n.d.ts.map +1 -1
- package/types/state/index.d.ts +10 -0
- package/types/state/index.d.ts.map +1 -1
- package/types/state/state-node.d.ts.map +1 -1
- package/types/state/utils/urls.d.ts.map +1 -1
- package/types/utils/json-pointer.d.ts +22 -0
- package/types/utils/json-pointer.d.ts.map +1 -0
- package/types/webmcp/index.d.ts +23 -15
- package/types/webmcp/index.d.ts.map +1 -1
- package/types/webmcp/project.d.ts +178 -57
- package/types/webmcp/project.d.ts.map +1 -1
- package/types/webmcp/resolve.d.ts +7 -3
- package/types/webmcp/resolve.d.ts.map +1 -1
- package/types/webmcp/schema.d.ts +44 -0
- package/types/webmcp/schema.d.ts.map +1 -0
- package/types/webmcp/suggestions-store.d.ts +82 -0
- package/types/webmcp/suggestions-store.d.ts.map +1 -0
- package/types/webmcp/tools/describe-state.d.ts +5 -57
- package/types/webmcp/tools/describe-state.d.ts.map +1 -1
- package/types/webmcp/tools/edit-array.d.ts +8 -37
- package/types/webmcp/tools/edit-array.d.ts.map +1 -1
- package/types/webmcp/tools/fill-form-skill.d.ts +10 -13
- package/types/webmcp/tools/fill-form-skill.d.ts.map +1 -1
- package/types/webmcp/tools/get-data.d.ts +17 -15
- package/types/webmcp/tools/get-data.d.ts.map +1 -1
- package/types/webmcp/tools/get-field-suggestions.d.ts +4 -30
- package/types/webmcp/tools/get-field-suggestions.d.ts.map +1 -1
- package/types/webmcp/tools/set-data.d.ts +17 -34
- package/types/webmcp/tools/set-data.d.ts.map +1 -1
- package/types/webmcp/tools/set-field-value.d.ts +20 -57
- package/types/webmcp/tools/set-field-value.d.ts.map +1 -1
- package/types/webmcp/variants-memo.d.ts +42 -0
- package/types/webmcp/variants-memo.d.ts.map +1 -0
package/src/webmcp/project.js
CHANGED
|
@@ -4,6 +4,190 @@
|
|
|
4
4
|
|
|
5
5
|
import { isItemsLayout } from '@json-layout/vocabulary'
|
|
6
6
|
|
|
7
|
+
import { visibleChildren, resolveNode } from './resolve.js'
|
|
8
|
+
import { projectDeclaredFields } from './schema.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Suggestion values can be arbitrarily large objects (a whole dataset definition for example),
|
|
12
|
+
* they are kept out of the tools output and retrieved by index with setFieldValue.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Longest value inlined in a suggestion listing. Only scalars are ever inlined: a picker
|
|
16
|
+
* shows a person titles, not the objects behind them, and an agent picks a row the same
|
|
17
|
+
* way, by index. Printing a slice of each object cost 61-77% of every suggestion response
|
|
18
|
+
* measured, for bytes the tool's own description tells the agent never to copy.
|
|
19
|
+
*/
|
|
20
|
+
export const SUGGESTION_VALUE_MAX_LENGTH = 100
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Longest value rendered in full anywhere the agent reads state. Beyond it a value is
|
|
24
|
+
* named rather than printed: a picked data-fair dataset is 4-13 KB of column schema, and
|
|
25
|
+
* echoing it on the write, again in the state tree and a third time from getData was the
|
|
26
|
+
* single largest cost in the eval — for content the agent applied by index and never had
|
|
27
|
+
* to handle.
|
|
28
|
+
*/
|
|
29
|
+
export const DISPLAYED_VALUE_MAX_LENGTH = 1000
|
|
30
|
+
|
|
31
|
+
/** Most revealed or hidden paths named before the list is summarised instead. */
|
|
32
|
+
export const REVEALED_PATHS_MAX = 10
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Longest rendered list of options inlined into a state line instead of being flagged as
|
|
36
|
+
* something to go and fetch. A closed enum is already in hand — the state layer resolves it
|
|
37
|
+
* into itemsCacheKey without a request — so flagging it sent the agent on a round trip for
|
|
38
|
+
* a list nobody had to look up: charts spent two of sixteen calls reading four-const enums,
|
|
39
|
+
* and sortBy, sortOrder, color and strValue would each have cost another.
|
|
40
|
+
*/
|
|
41
|
+
export const INLINE_ITEMS_MAX_LENGTH = 200
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The options of a node when they are already resolved, and short enough to say out loud.
|
|
45
|
+
*
|
|
46
|
+
* itemsCacheKey is what the state layer fetched or evaluated for this node: an array once
|
|
47
|
+
* the options are known locally, the resolved URL string for a remote picker. So an array
|
|
48
|
+
* is exactly the case where getFieldSuggestions would tell the agent something the form
|
|
49
|
+
* could already have said.
|
|
50
|
+
* @param {import('../state/types.js').StateNode} node
|
|
51
|
+
* @returns {string | undefined} the rendered list, or undefined to keep flagging it
|
|
52
|
+
*/
|
|
53
|
+
function inlineItems (node) {
|
|
54
|
+
const items = /** @type {any} */(node).itemsCacheKey
|
|
55
|
+
if (!Array.isArray(items) || items.length === 0) return undefined
|
|
56
|
+
const values = items.map((item) => (item && typeof item === 'object' && 'value' in item) ? item.value : item)
|
|
57
|
+
// only a short scalar can be written straight back; an object value has to be applied by
|
|
58
|
+
// suggestionIndex, so stating it would cost bytes and still leave the agent a lookup
|
|
59
|
+
if (!values.every((v) => typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean')) return undefined
|
|
60
|
+
const rendered = JSON.stringify(values)
|
|
61
|
+
return rendered.length <= INLINE_ITEMS_MAX_LENGTH ? rendered : undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Longest help inlined on a node the agent did not ask about. Help is written for someone
|
|
66
|
+
* looking at a form, where it sits behind a "?" icon and is read on demand; inlined into
|
|
67
|
+
* every state read it is pushed instead, and portal-page spends 782 characters of SEO
|
|
68
|
+
* advice on a field no agent in the eval has ever filled. Past this length it is named and
|
|
69
|
+
* left to be fetched, the same bargain oversized values get. Short help stays inline
|
|
70
|
+
* whatever the node — it is the kind that changes what an agent writes, such as a negative
|
|
71
|
+
* height meaning automatic sizing.
|
|
72
|
+
*/
|
|
73
|
+
export const HELP_MAX_LENGTH = 300
|
|
74
|
+
|
|
75
|
+
/** the few named entities that show up in form help, plus the numeric forms */
|
|
76
|
+
const NAMED_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' }
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Help is authored as HTML for a browser. An agent reads text, so the markup is pure cost —
|
|
80
|
+
* `'` is not merely wasted, it is harder to read than the apostrophe it stands for —
|
|
81
|
+
* and the newlines between block tags break the one-line-per-node markdown the state tree
|
|
82
|
+
* is made of.
|
|
83
|
+
* @param {string} html
|
|
84
|
+
* @returns {string}
|
|
85
|
+
*/
|
|
86
|
+
export function helpToText (html) {
|
|
87
|
+
return html
|
|
88
|
+
.replace(/<li\b[^>]*>/gi, ' - ')
|
|
89
|
+
.replace(/<[^>]+>/g, ' ')
|
|
90
|
+
.replace(/&#(\d+);/g, (_, d) => String.fromCharCode(Number(d)))
|
|
91
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCharCode(parseInt(h, 16)))
|
|
92
|
+
.replace(/&([a-z]+);/gi, (m, name) => /** @type {any} */(NAMED_ENTITIES)[name.toLowerCase()] ?? m)
|
|
93
|
+
.replace(/\s+/g, ' ')
|
|
94
|
+
.trim()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Which nodes are currently rendered, by path. A node hidden by a layout `if` stays in
|
|
99
|
+
* the tree as comp "none", so what a write changes there is visibility rather than the
|
|
100
|
+
* set of paths — comparing paths alone would report nothing. A node governed by a schema
|
|
101
|
+
* if/then is the other way round: it is absent until the condition holds, so the set of
|
|
102
|
+
* paths is all there is to compare. Recording both facts lets one diff serve both.
|
|
103
|
+
* @param {import('../state/types.js').StateNode} node
|
|
104
|
+
* @param {Map<string, {comp: string, owns: boolean}>} [into]
|
|
105
|
+
* @returns {Map<string, {comp: string, owns: boolean}>}
|
|
106
|
+
*/
|
|
107
|
+
export function visibilitySnapshot (node, into = new Map()) {
|
|
108
|
+
// `owns` separates a field from the wrappers around it. A condition turning true brings
|
|
109
|
+
// its `$then` section along with the fields inside it, and naming the section among the
|
|
110
|
+
// things that "became available" would point the agent at a path it cannot write.
|
|
111
|
+
if (node.fullKey !== undefined) into.set(node.fullKey, { comp: node.layout?.comp, owns: node.dataPath !== node.parentDataPath })
|
|
112
|
+
for (const child of node.children ?? []) visibilitySnapshot(child, into)
|
|
113
|
+
return into
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* What a write turned visible or invisible.
|
|
118
|
+
*
|
|
119
|
+
* A field can arrive two ways: a layout `if` toggles a node that already exists between
|
|
120
|
+
* comp "none" and its real component, while a schema-level if/then has no node at all
|
|
121
|
+
* until the condition holds and then creates one. Both are the same event to an agent —
|
|
122
|
+
* something it must now fill that it could not before — so both count.
|
|
123
|
+
*
|
|
124
|
+
* `activated` is the variant selector a write just switched, if any. Activating a variant
|
|
125
|
+
* replaces a whole branch, and setFieldValue already lists the branch it activated;
|
|
126
|
+
* counting those nodes here too would print the same subtree twice.
|
|
127
|
+
* @param {Map<string, {comp: string, owns: boolean}>} before
|
|
128
|
+
* @param {Map<string, {comp: string, owns: boolean}>} after
|
|
129
|
+
* @param {string} [activated] - fullKey of a variant selector whose subtree is reported elsewhere
|
|
130
|
+
* @returns {{ revealed: string[], hidden: string[] }}
|
|
131
|
+
*/
|
|
132
|
+
export function diffVisibility (before, after, activated) {
|
|
133
|
+
/** @type {string[]} */
|
|
134
|
+
const revealed = []
|
|
135
|
+
/** @type {string[]} */
|
|
136
|
+
const hidden = []
|
|
137
|
+
/** @param {string} path */
|
|
138
|
+
const reportedElsewhere = (path) => activated !== undefined && (path === activated || path.startsWith(activated + '/'))
|
|
139
|
+
for (const [path, node] of after) {
|
|
140
|
+
if (reportedElsewhere(path)) continue
|
|
141
|
+
const was = before.get(path)
|
|
142
|
+
if (was === undefined) {
|
|
143
|
+
if (node.comp !== 'none' && node.owns) revealed.push(path)
|
|
144
|
+
} else if (was.comp === 'none' && node.comp !== 'none') revealed.push(path)
|
|
145
|
+
else if (was.comp !== 'none' && node.comp === 'none') hidden.push(path)
|
|
146
|
+
}
|
|
147
|
+
// A condition turning false takes its subtree away entirely rather than hiding it. That
|
|
148
|
+
// is worth one line: an agent holding a path from an earlier describeState would
|
|
149
|
+
// otherwise keep trying to write somewhere that no longer exists.
|
|
150
|
+
for (const [path, node] of before) {
|
|
151
|
+
if (after.has(path) || reportedElsewhere(path)) continue
|
|
152
|
+
if (node.comp !== 'none' && node.owns) hidden.push(path)
|
|
153
|
+
}
|
|
154
|
+
return { revealed, hidden }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* @param {{ revealed: string[], hidden: string[] }} diff
|
|
159
|
+
* @returns {string}
|
|
160
|
+
*/
|
|
161
|
+
export function formatVisibilityDiff (diff) {
|
|
162
|
+
/**
|
|
163
|
+
* @param {string[]} paths
|
|
164
|
+
* @param {string} what
|
|
165
|
+
* @returns {string}
|
|
166
|
+
*/
|
|
167
|
+
const line = (paths, what) => {
|
|
168
|
+
if (!paths.length) return ''
|
|
169
|
+
const shown = paths.slice(0, REVEALED_PATHS_MAX).join(', ')
|
|
170
|
+
const rest = paths.length > REVEALED_PATHS_MAX ? `, and ${paths.length - REVEALED_PATHS_MAX} more` : ''
|
|
171
|
+
return `\n${paths.length} field(s) ${what}: ${shown}${rest}`
|
|
172
|
+
}
|
|
173
|
+
return line(diff.revealed, 'became available') + line(diff.hidden, 'are no longer available')
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Render a value for the agent: in full when it is small enough to be worth reading,
|
|
178
|
+
* otherwise named with its kind and size so the agent knows what is there without paying
|
|
179
|
+
* for it. It can always read a node's own subtree with describeState.
|
|
180
|
+
* @param {unknown} value
|
|
181
|
+
* @returns {string | undefined}
|
|
182
|
+
*/
|
|
183
|
+
export function abbreviateValue (value) {
|
|
184
|
+
const json = JSON.stringify(value)
|
|
185
|
+
if (json === undefined || json.length <= DISPLAYED_VALUE_MAX_LENGTH) return json
|
|
186
|
+
if (Array.isArray(value)) return `<array of ${value.length} items, ${json.length} chars — call getData with this path to read it>`
|
|
187
|
+
if (value !== null && typeof value === 'object') return `<object, ${json.length} chars — call getData with this path to read it>`
|
|
188
|
+
return `<${typeof value}, ${json.length} chars>`
|
|
189
|
+
}
|
|
190
|
+
|
|
7
191
|
const constraintKeys = {
|
|
8
192
|
'number-field': ['min', 'max', 'step', 'precision'],
|
|
9
193
|
slider: ['min', 'max', 'step'],
|
|
@@ -46,77 +230,131 @@ function getConstraintKeys (comp) {
|
|
|
46
230
|
}
|
|
47
231
|
|
|
48
232
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* label?: string,
|
|
55
|
-
* help?: string,
|
|
56
|
-
* error?: string,
|
|
57
|
-
* required?: boolean,
|
|
58
|
-
* readOnly?: boolean,
|
|
59
|
-
* modified?: boolean,
|
|
60
|
-
* constraints?: Record<string, unknown>,
|
|
61
|
-
* variants?: Array<{index: number, title: string}>,
|
|
62
|
-
* selectedVariant?: number,
|
|
63
|
-
* children?: Array<ProjectedNode>,
|
|
64
|
-
* getSuggestions?: boolean
|
|
65
|
-
* }} ProjectedNode
|
|
233
|
+
* A list item rendered as a summary is read-only only because the list did not activate it,
|
|
234
|
+
* the agent should not be told that this item cannot be edited.
|
|
235
|
+
* @param {import('../state/types.js').StateNode} node
|
|
236
|
+
* @param {import('../state/index.js').StatefulLayout} statefulLayout
|
|
237
|
+
* @returns {boolean}
|
|
66
238
|
*/
|
|
239
|
+
function isEditableListItemSummary (node, statefulLayout) {
|
|
240
|
+
if (!node.options.summary) return false
|
|
241
|
+
if (node.parentFullKey === null || node.parentFullKey === undefined) return false
|
|
242
|
+
const parent = resolveNode(statefulLayout.stateTree.root, node.parentFullKey)
|
|
243
|
+
if (!parent || parent.layout.comp !== 'list' || parent.options.readOnly) return false
|
|
244
|
+
// a list that does not allow item edition really has read-only items
|
|
245
|
+
const listActions = /** @type {Record<string, unknown>} */(parent.layout).listActions
|
|
246
|
+
if (Array.isArray(listActions) && !listActions.includes('edit')) return false
|
|
247
|
+
return true
|
|
248
|
+
}
|
|
67
249
|
|
|
68
250
|
/**
|
|
251
|
+
* readOnly is inherited by everything below a list item rendered as a summary, so the exemption
|
|
252
|
+
* has to look at the ancestors too: the fields of such an item are writable, and presenting them
|
|
253
|
+
* as read-only makes an agent skip fields it is allowed to fill. Only walked for a readOnly node.
|
|
69
254
|
* @param {import('../state/types.js').StateNode} node
|
|
70
255
|
* @param {import('../state/index.js').StatefulLayout} statefulLayout
|
|
71
|
-
* @returns {
|
|
256
|
+
* @returns {boolean}
|
|
72
257
|
*/
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
258
|
+
function isReadOnly (node, statefulLayout) {
|
|
259
|
+
if (!node.options.readOnly) return false
|
|
260
|
+
/** @type {import('../state/types.js').StateNode|undefined} */
|
|
261
|
+
let current = node
|
|
262
|
+
while (current) {
|
|
263
|
+
if (isEditableListItemSummary(current, statefulLayout)) return false
|
|
264
|
+
const parentFullKey = current.parentFullKey
|
|
265
|
+
if (parentFullKey === null || parentFullKey === undefined) break
|
|
266
|
+
current = resolveNode(statefulLayout.stateTree.root, parentFullKey)
|
|
79
267
|
}
|
|
268
|
+
return true
|
|
269
|
+
}
|
|
80
270
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
271
|
+
/**
|
|
272
|
+
* Validation errors of the whole state tree indexed by node path.
|
|
273
|
+
* Only the first of the two occurrences of an activated list item captures the errors, so a
|
|
274
|
+
* node reached through the editable occurrence has to look its own error up by path.
|
|
275
|
+
* @param {import('../state/types.js').StateNode} root
|
|
276
|
+
* @returns {Record<string, string>}
|
|
277
|
+
*/
|
|
278
|
+
function indexErrorsByPath (root) {
|
|
279
|
+
/** @type {Record<string, string>} */
|
|
280
|
+
const byPath = {}
|
|
281
|
+
/** @param {import('../state/types.js').StateNode} node */
|
|
282
|
+
const recurse = (node) => {
|
|
283
|
+
if (node.error && byPath[node.fullKey] === undefined) byPath[node.fullKey] = node.error
|
|
284
|
+
for (const child of node.children ?? []) recurse(child)
|
|
285
|
+
}
|
|
286
|
+
recurse(root)
|
|
287
|
+
return byPath
|
|
288
|
+
}
|
|
87
289
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
290
|
+
/**
|
|
291
|
+
* A node whose value is picked as a whole from getItems (a select or an autocomplete over
|
|
292
|
+
* objects) declares properties that are not nodes of the form: it is filled from
|
|
293
|
+
* getFieldSuggestions, never field by field. A list is itemsBased too, but its items do
|
|
294
|
+
* become real nodes, so its declared fields remain useful.
|
|
295
|
+
* @param {import('../state/types.js').StateNode} node
|
|
296
|
+
* @param {import('../state/index.js').StatefulLayout} statefulLayout
|
|
297
|
+
* @returns {boolean}
|
|
298
|
+
*/
|
|
299
|
+
function isValuePickedFromItems (node, statefulLayout) {
|
|
300
|
+
if (node.layout.comp === 'list') return false
|
|
301
|
+
return isItemsLayout(node.layout, statefulLayout.compiledLayout.components)
|
|
302
|
+
}
|
|
92
303
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
304
|
+
/**
|
|
305
|
+
* Whether this field's options cannot be fetched yet because the request that would
|
|
306
|
+
* produce them cannot be built.
|
|
307
|
+
*
|
|
308
|
+
* The state layer resolves a remote list's URL up front and stores it as `itemsCacheKey`;
|
|
309
|
+
* when the expression THROWS — because it reads a field nobody has filled in — the key is
|
|
310
|
+
* null. That is a different situation from "your query matched nothing" and from "this
|
|
311
|
+
* field has no list", and all three used to arrive as the same four words. The review that
|
|
312
|
+
* prompted this put 45% of the option lists across thirty real applications in this state
|
|
313
|
+
* until some other field is written first, so it is the common case, not an edge.
|
|
314
|
+
* @param {import('../state/types.js').StateNode} node
|
|
315
|
+
* @returns {boolean}
|
|
316
|
+
*/
|
|
317
|
+
export function suggestionsBlocked (node) {
|
|
318
|
+
return node.itemsCacheKey === null
|
|
319
|
+
}
|
|
103
320
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
321
|
+
/**
|
|
322
|
+
* The expression a blocked list is waiting on, so the answer can say what to go and set.
|
|
323
|
+
* @param {import('../state/types.js').StateNode} node
|
|
324
|
+
* @returns {string|undefined}
|
|
325
|
+
*/
|
|
326
|
+
export function suggestionsSource (node) {
|
|
327
|
+
const getItems = /** @type {any} */(node.layout).getItems
|
|
328
|
+
const expr = getItems?.url?.expr ?? getItems?.expr
|
|
329
|
+
return typeof expr === 'string' ? expr : undefined
|
|
330
|
+
}
|
|
112
331
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
332
|
+
/**
|
|
333
|
+
* Whether this node can actually answer getFieldSuggestions.
|
|
334
|
+
*
|
|
335
|
+
* isItemsLayout only says the component KIND is items-based; it is true of a plain array
|
|
336
|
+
* of strings, which renders as a combobox and has no source of items at all. The state
|
|
337
|
+
* layer asks a stricter question — state-node.js gates prefetching on
|
|
338
|
+
* `layout.items || layout.getItems`, and index.js throws "missing items or getItems
|
|
339
|
+
* parameters" when neither produces any — so announcing the flag on kind alone promises
|
|
340
|
+
* the agent something the tool cannot deliver. The fill-form guide tells agents they MUST
|
|
341
|
+
* call getFieldSuggestions whenever they see the flag, so they obey and hit that error.
|
|
342
|
+
* @param {import('../state/types.js').StateNode} node
|
|
343
|
+
* @param {import('../state/index.js').StatefulLayout} statefulLayout
|
|
344
|
+
* @returns {boolean}
|
|
345
|
+
*/
|
|
346
|
+
function hasSuggestions (node, statefulLayout) {
|
|
347
|
+
if (!isItemsLayout(node.layout, statefulLayout.compiledLayout.components)) return false
|
|
348
|
+
return !!(node.layout.items ?? node.layout.getItems)
|
|
349
|
+
}
|
|
118
350
|
|
|
119
|
-
|
|
351
|
+
/**
|
|
352
|
+
* @param {import('../state/types.js').StateNode} node
|
|
353
|
+
* @param {Record<string, string>} [errorsByPath]
|
|
354
|
+
* @returns {string|undefined}
|
|
355
|
+
*/
|
|
356
|
+
function nodeError (node, errorsByPath) {
|
|
357
|
+
return node.error ?? errorsByPath?.[node.fullKey]
|
|
120
358
|
}
|
|
121
359
|
|
|
122
360
|
/**
|
|
@@ -132,42 +370,36 @@ export function projectFieldResult (node, statefulLayout) {
|
|
|
132
370
|
type: compToType[node.layout.comp] || node.layout.comp,
|
|
133
371
|
data: node.data
|
|
134
372
|
}
|
|
135
|
-
|
|
373
|
+
const error = nodeError(node, indexErrorsByPath(statefulLayout.stateTree.root))
|
|
374
|
+
if (error) out.error = error
|
|
136
375
|
return out
|
|
137
376
|
}
|
|
138
377
|
|
|
139
|
-
/**
|
|
140
|
-
* @param {import('../state/types.js').StateTree} stateTree
|
|
141
|
-
* @param {import('../state/index.js').StatefulLayout} statefulLayout
|
|
142
|
-
* @returns {{ root: ProjectedNode, valid: boolean }}
|
|
143
|
-
*/
|
|
144
|
-
export function projectStateTree (stateTree, statefulLayout) {
|
|
145
|
-
return {
|
|
146
|
-
root: projectNode(stateTree.root, statefulLayout),
|
|
147
|
-
valid: stateTree.valid
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
378
|
/**
|
|
152
379
|
* Format a projected node as a markdown line for LLM-readable output.
|
|
153
380
|
* @param {import('../state/types.js').StateNode} node
|
|
154
381
|
* @param {import('../state/index.js').StatefulLayout} statefulLayout
|
|
155
382
|
* @param {number} [depth]
|
|
383
|
+
* @param {Record<string, string>} [errorsByPath] - computed on the root node when not given
|
|
384
|
+
* @param {import('./variants-memo.js').VariantsMemo} [variantsMemo] - when given, a variant
|
|
385
|
+
* list already printed for the same schema node is replaced by a pointer back to it
|
|
156
386
|
* @returns {string}
|
|
157
387
|
*/
|
|
158
|
-
export function projectNodeToMarkdown (node, statefulLayout, depth = 0) {
|
|
388
|
+
export function projectNodeToMarkdown (node, statefulLayout, depth = 0, errorsByPath = indexErrorsByPath(statefulLayout.stateTree.root), variantsMemo) {
|
|
159
389
|
const indent = ' '.repeat(depth)
|
|
160
390
|
const type = compToType[node.layout.comp] || node.layout.comp
|
|
161
391
|
const layout = /** @type {Record<string, unknown>} */(node.layout)
|
|
162
392
|
|
|
163
393
|
// build metadata tags
|
|
164
394
|
const meta = [type]
|
|
395
|
+
const error = nodeError(node, errorsByPath)
|
|
165
396
|
if (node.skeleton.required) meta.push('required')
|
|
166
|
-
if (node
|
|
167
|
-
if (
|
|
397
|
+
if (isReadOnly(node, statefulLayout)) meta.push('readOnly')
|
|
398
|
+
if (error) meta.push('error')
|
|
168
399
|
if (node.modified) meta.push('modified')
|
|
169
400
|
|
|
170
|
-
// constraints
|
|
401
|
+
// constraints, from the layout for what the component renders and from the skeleton for
|
|
402
|
+
// what ajv enforces but nothing else would say — a precompiled layout has no raw schema
|
|
171
403
|
const keys = getConstraintKeys(node.layout.comp)
|
|
172
404
|
if (keys) {
|
|
173
405
|
for (const k of keys) {
|
|
@@ -175,6 +407,9 @@ export function projectNodeToMarkdown (node, statefulLayout, depth = 0) {
|
|
|
175
407
|
if (v !== undefined && v !== null) meta.push(`${k}=${v}`)
|
|
176
408
|
}
|
|
177
409
|
}
|
|
410
|
+
for (const [k, v] of Object.entries(node.skeleton.constraints ?? {})) {
|
|
411
|
+
meta.push(`${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`)
|
|
412
|
+
}
|
|
178
413
|
|
|
179
414
|
// variants
|
|
180
415
|
if (node.layout.comp === 'one-of-select' && Array.isArray(layout.oneOfItems)) {
|
|
@@ -182,7 +417,16 @@ export function projectNodeToMarkdown (node, statefulLayout, depth = 0) {
|
|
|
182
417
|
if (selected) meta.push(`selected=${selected.key}`)
|
|
183
418
|
}
|
|
184
419
|
|
|
185
|
-
if (
|
|
420
|
+
if (hasSuggestions(node, statefulLayout)) {
|
|
421
|
+
// a closed list is stated, not advertised: the guide tells the agent it must fetch
|
|
422
|
+
// whatever is flagged, so flagging what is already known is what bought the round trip
|
|
423
|
+
const inlined = inlineItems(node)
|
|
424
|
+
if (inlined) meta.push(`values=${inlined}`)
|
|
425
|
+
// and a list that cannot be fetched yet says so here, before the agent spends a call
|
|
426
|
+
// finding out — the answer it would get names no cause it could act on
|
|
427
|
+
else if (suggestionsBlocked(node)) meta.push('suggestions once another field is set')
|
|
428
|
+
else meta.push('suggestions')
|
|
429
|
+
}
|
|
186
430
|
|
|
187
431
|
// array item count
|
|
188
432
|
if (node.layout.comp === 'list' && Array.isArray(node.data)) {
|
|
@@ -195,28 +439,66 @@ export function projectNodeToMarkdown (node, statefulLayout, depth = 0) {
|
|
|
195
439
|
if (typeof layout.label === 'string') line += ` label="${layout.label}"`
|
|
196
440
|
else if (typeof layout.title === 'string') line += ` title="${layout.title}"`
|
|
197
441
|
|
|
442
|
+
const children = visibleChildren(node)
|
|
443
|
+
|
|
198
444
|
// value for leaf nodes (no children or empty children)
|
|
199
|
-
if (
|
|
200
|
-
line += ` value=${
|
|
445
|
+
if (children.length === 0) {
|
|
446
|
+
line += ` value=${abbreviateValue(node.data)}`
|
|
201
447
|
}
|
|
202
448
|
|
|
203
|
-
|
|
449
|
+
// Help is the guidance a model cannot infer — that a negative height means automatic
|
|
450
|
+
// sizing, say. Long help is named rather than printed unless this node is the one that
|
|
451
|
+
// was asked about.
|
|
452
|
+
if (typeof node.layout.help === 'string' && node.layout.help) {
|
|
453
|
+
const help = helpToText(node.layout.help)
|
|
454
|
+
if (help.length <= HELP_MAX_LENGTH || depth === 0) line += ` help="${help}"`
|
|
455
|
+
else if (help) line += ` help=<${help.length} chars — describeState ${path} to read it>`
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (error) line += ` — ${error}`
|
|
204
459
|
|
|
205
460
|
const lines = [line]
|
|
206
461
|
|
|
207
462
|
// variants list
|
|
208
463
|
if (node.layout.comp === 'one-of-select' && Array.isArray(layout.oneOfItems)) {
|
|
209
464
|
const variants = layout.oneOfItems.filter((item) => !item.header)
|
|
210
|
-
|
|
211
|
-
|
|
465
|
+
// Which branch is live was only ever implied, by the index in the path of the section
|
|
466
|
+
// printed underneath. That reads as "a branch exists" rather than "this one is
|
|
467
|
+
// active", and an agent that cannot tell the two apart cannot tell a switch that
|
|
468
|
+
// worked from one that did nothing — which is the whole question it asks a variant
|
|
469
|
+
// selector. The activated branch is always this node's first child.
|
|
470
|
+
const activeKey = node.children?.[0]?.key
|
|
471
|
+
const listedAt = variantsMemo?.listedAt(node.skeleton.pointer)
|
|
472
|
+
if (listedAt === undefined) {
|
|
473
|
+
variantsMemo?.record(node.skeleton.pointer, path)
|
|
474
|
+
for (const v of variants) {
|
|
475
|
+
lines.push(`${indent} - variant ${v.key}: ${v.title}${v.key === activeKey ? ' (active)' : ''}`)
|
|
476
|
+
}
|
|
477
|
+
} else {
|
|
478
|
+
// a recursive schema reaches the same union at many paths; the list is a constant,
|
|
479
|
+
// so name where it was given rather than repeat it — but which branch is active is
|
|
480
|
+
// this node's own, so it still has to be said here
|
|
481
|
+
const active = variants.find((v) => v.key === activeKey)
|
|
482
|
+
const activeLabel = active ? ` (variant ${active.key}: ${active.title} active)` : ''
|
|
483
|
+
lines.push(`${indent} - ${variants.length} variants${activeLabel}, the same list already given for ${listedAt} — call describeState on ${path} to see them again`)
|
|
212
484
|
}
|
|
213
485
|
}
|
|
214
486
|
|
|
215
487
|
// recurse children
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
488
|
+
for (const child of children) {
|
|
489
|
+
lines.push(projectNodeToMarkdown(child, statefulLayout, depth + 1, errorsByPath, variantsMemo))
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// fields known from the skeleton but not hydrated in the state tree, skipped on a node fed by
|
|
493
|
+
// getItems: its properties are not separate nodes, it is filled from getFieldSuggestions
|
|
494
|
+
if (children.length === 0 && node.skeleton.children?.length &&
|
|
495
|
+
!isValuePickedFromItems(node, statefulLayout)) {
|
|
496
|
+
for (const field of projectDeclaredFields(node, statefulLayout)) {
|
|
497
|
+
const fieldMeta = ['declared']
|
|
498
|
+
if (field.type) fieldMeta.unshift(field.type)
|
|
499
|
+
if (field.required) fieldMeta.push('required')
|
|
500
|
+
if (field.enum) fieldMeta.push(`enum=${JSON.stringify(field.enum)}`)
|
|
501
|
+
lines.push(`${indent} - ${field.path} (${fieldMeta.join(', ')})`)
|
|
220
502
|
}
|
|
221
503
|
}
|
|
222
504
|
|
|
@@ -227,10 +509,11 @@ export function projectNodeToMarkdown (node, statefulLayout, depth = 0) {
|
|
|
227
509
|
* Format a state tree as markdown for LLM-readable output.
|
|
228
510
|
* @param {import('../state/types.js').StateTree} stateTree
|
|
229
511
|
* @param {import('../state/index.js').StatefulLayout} statefulLayout
|
|
512
|
+
* @param {import('./variants-memo.js').VariantsMemo} [variantsMemo]
|
|
230
513
|
* @returns {string}
|
|
231
514
|
*/
|
|
232
|
-
export function projectStateTreeToMarkdown (stateTree, statefulLayout) {
|
|
233
|
-
const errors = collectErrors(
|
|
515
|
+
export function projectStateTreeToMarkdown (stateTree, statefulLayout, variantsMemo) {
|
|
516
|
+
const errors = collectErrors(statefulLayout)
|
|
234
517
|
const validLine = stateTree.valid
|
|
235
518
|
? 'valid: true, no errors'
|
|
236
519
|
: `valid: false, ${errors.length} error(s)`
|
|
@@ -246,7 +529,7 @@ export function projectStateTreeToMarkdown (stateTree, statefulLayout) {
|
|
|
246
529
|
}
|
|
247
530
|
|
|
248
531
|
lines.push('Fields:')
|
|
249
|
-
lines.push(projectNodeToMarkdown(stateTree.root, statefulLayout, 0))
|
|
532
|
+
lines.push(projectNodeToMarkdown(stateTree.root, statefulLayout, 0, undefined, variantsMemo))
|
|
250
533
|
|
|
251
534
|
return lines.join('\n')
|
|
252
535
|
}
|
|
@@ -254,14 +537,34 @@ export function projectStateTreeToMarkdown (stateTree, statefulLayout) {
|
|
|
254
537
|
/**
|
|
255
538
|
* Format a mutation result as concise text for LLM-readable output.
|
|
256
539
|
* @param {boolean} valid
|
|
257
|
-
* @param {Array<{path: string, message: string}>} errors
|
|
540
|
+
* @param {Array<{path: string, message: string}>} errors - errors of the mutated subtree
|
|
258
541
|
* @param {string} [prefix] - optional prefix line (e.g. field info)
|
|
542
|
+
* @param {number} [otherErrors] - number of errors of the form outside of the mutated subtree
|
|
259
543
|
* @returns {string}
|
|
260
544
|
*/
|
|
261
|
-
export function formatMutationResult (valid, errors, prefix) {
|
|
545
|
+
export function formatMutationResult (valid, errors, prefix, otherErrors) {
|
|
262
546
|
const lines = []
|
|
263
547
|
if (prefix) lines.push(prefix)
|
|
264
548
|
|
|
549
|
+
// scoped mode, the errors are the ones of the mutated subtree only
|
|
550
|
+
if (otherErrors !== undefined) {
|
|
551
|
+
if (errors.length === 0) lines.push('no error here')
|
|
552
|
+
else {
|
|
553
|
+
lines.push(`${errors.length} error(s) here:`)
|
|
554
|
+
for (const e of errors) {
|
|
555
|
+
lines.push(`- ${e.path}: ${e.message}`)
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
if (otherErrors > 0) {
|
|
559
|
+
lines.push(`form has ${otherErrors} other error(s) elsewhere, use describeState to list them`)
|
|
560
|
+
} else if (!valid) {
|
|
561
|
+
lines.push('form is invalid')
|
|
562
|
+
} else {
|
|
563
|
+
lines.push('form is valid')
|
|
564
|
+
}
|
|
565
|
+
return lines.join('\n')
|
|
566
|
+
}
|
|
567
|
+
|
|
265
568
|
if (valid) {
|
|
266
569
|
lines.push('valid, no errors')
|
|
267
570
|
} else {
|
|
@@ -278,46 +581,174 @@ export function formatMutationResult (valid, errors, prefix) {
|
|
|
278
581
|
}
|
|
279
582
|
|
|
280
583
|
/**
|
|
281
|
-
*
|
|
584
|
+
* @typedef {{index: number, title: string, key?: string, value?: unknown, valueOmitted?: boolean, valueLength?: number}} ProjectedSuggestion
|
|
585
|
+
*/
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Project suggestions for the tools output: anything but a short scalar is identified by
|
|
589
|
+
* its title and key alone, and referred to by index instead of copied around.
|
|
282
590
|
* @param {Array<{value: unknown, title: string, key?: string}>} items
|
|
591
|
+
* @param {number} [baseIndex] - index of the first item, as the store assigned it
|
|
592
|
+
* @returns {ProjectedSuggestion[]}
|
|
593
|
+
*/
|
|
594
|
+
export function projectSuggestions (items, baseIndex = 0) {
|
|
595
|
+
return items.map((item, index) => {
|
|
596
|
+
/** @type {ProjectedSuggestion} */
|
|
597
|
+
const out = { index: baseIndex + index, title: item.title }
|
|
598
|
+
if (item.key !== undefined && item.key !== item.title) out.key = item.key
|
|
599
|
+
const json = JSON.stringify(item.value)
|
|
600
|
+
if (json === undefined) return out
|
|
601
|
+
// Short scalars stay: agents batch those straight into setData rather than spending a
|
|
602
|
+
// round-trip applying an index. Anything else is identified by its title and key, and
|
|
603
|
+
// applied by index — the value itself never has to reach the agent.
|
|
604
|
+
const isScalar = item.value === null || ['string', 'number', 'boolean'].includes(typeof item.value)
|
|
605
|
+
if (isScalar && json.length <= SUGGESTION_VALUE_MAX_LENGTH) {
|
|
606
|
+
out.value = item.value
|
|
607
|
+
} else {
|
|
608
|
+
out.valueOmitted = true
|
|
609
|
+
out.valueLength = json.length
|
|
610
|
+
}
|
|
611
|
+
return out
|
|
612
|
+
})
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Format field suggestions as markdown for LLM-readable output.
|
|
617
|
+
* @param {ProjectedSuggestion[]} suggestions
|
|
618
|
+
* @param {string} [blockedOn] - the expression the list is waiting on, when it has one
|
|
283
619
|
* @returns {string}
|
|
284
620
|
*/
|
|
285
|
-
export function formatSuggestions (
|
|
286
|
-
if (
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
621
|
+
export function formatSuggestions (suggestions, blockedOn) {
|
|
622
|
+
if (suggestions.length === 0) {
|
|
623
|
+
if (blockedOn) return `No options yet: this field's list comes from \`${blockedOn}\`, and that cannot be resolved until the data it reads is set. Fill that field first, then ask again.`
|
|
624
|
+
return 'No option matched. The list exists but nothing came back for this query — try a broader one, or omit the query to see what there is.'
|
|
625
|
+
}
|
|
626
|
+
const lines = [`${suggestions.length} suggestion(s), apply one with setFieldValue and its suggestionIndex (or copy a short value):`]
|
|
627
|
+
for (const suggestion of suggestions) {
|
|
628
|
+
const title = suggestion.key ? `${suggestion.title} (${suggestion.key})` : suggestion.title
|
|
629
|
+
if (suggestion.valueOmitted) {
|
|
630
|
+
lines.push(`- [${suggestion.index}] ${title} — apply with suggestionIndex=${suggestion.index}`)
|
|
292
631
|
} else {
|
|
293
|
-
lines.push(`-
|
|
632
|
+
lines.push(`- [${suggestion.index}] ${title} — value=${JSON.stringify(suggestion.value)}`)
|
|
294
633
|
}
|
|
295
634
|
}
|
|
296
635
|
return lines.join('\n')
|
|
297
636
|
}
|
|
298
637
|
|
|
299
638
|
/**
|
|
300
|
-
*
|
|
639
|
+
* The data pointer an ajv error applies to.
|
|
640
|
+
*
|
|
641
|
+
* ajv-errors wraps the original error, and a `required` error reports the parent's
|
|
642
|
+
* pointer with the missing key in its params — so the naive instancePath would be the
|
|
643
|
+
* object, not the field.
|
|
644
|
+
* @param {any} error
|
|
645
|
+
* @returns {string}
|
|
646
|
+
*/
|
|
647
|
+
function dataPointerOf (error) {
|
|
648
|
+
const original = error?.params?.errors?.[0] ?? error
|
|
649
|
+
if (original?.keyword === 'required' && original.params?.missingProperty) {
|
|
650
|
+
return `${original.instancePath}/${original.params.missingProperty}`
|
|
651
|
+
}
|
|
652
|
+
return original?.instancePath ?? ''
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Whether an error belongs to a branch of a union the form is actually showing.
|
|
657
|
+
*
|
|
658
|
+
* ajv validates every branch of a `oneOf` and reports the failures of all of them, so
|
|
659
|
+
* while the chosen branch is still incomplete the losing branches complain too. Those
|
|
660
|
+
* complaints name properties of a shape that was not chosen: there is no node for them,
|
|
661
|
+
* no way to write them and no way to clear them. The state tree already draws this line —
|
|
662
|
+
* it matches an error to a node by schema pointer, so only the active branch's errors
|
|
663
|
+
* ever land on one — and this keeps the raw list that is appended afterwards to the same
|
|
664
|
+
* line rather than undoing the work.
|
|
665
|
+
*
|
|
666
|
+
* The test is per `oneOf` crossed on the way down, not on the error's path as a whole: an
|
|
667
|
+
* error below an unhydrated list item has no node either, and that one must survive.
|
|
668
|
+
* @param {any} error
|
|
669
|
+
* @param {Set<string>} renderedPointers - skeleton pointers of the nodes the form has built
|
|
670
|
+
* @returns {boolean}
|
|
671
|
+
*/
|
|
672
|
+
function isRenderedBranch (error, renderedPointers) {
|
|
673
|
+
for (const schemaPath of [error?.schemaPath, error?.params?.errors?.[0]?.schemaPath]) {
|
|
674
|
+
if (typeof schemaPath !== 'string') continue
|
|
675
|
+
const branches = /\/oneOf\/\d+/g
|
|
676
|
+
let match
|
|
677
|
+
while ((match = branches.exec(schemaPath)) !== null) {
|
|
678
|
+
if (!renderedPointers.has(schemaPath.slice(0, match.index + match[0].length))) return false
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
return true
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Errors of the whole form, each named by the location it actually applies to.
|
|
686
|
+
*
|
|
687
|
+
* A node only carries an error while it is hydrated. A list shows its items in summary
|
|
688
|
+
* mode, so nothing below an unedited item exists as a node, and every error under it
|
|
689
|
+
* collapses onto the list — one message, on a path that is not the faulty one. An agent
|
|
690
|
+
* told "/sections must be integer" knows it is wrong and not where, and retries blind.
|
|
691
|
+
*
|
|
692
|
+
* So a node error that is standing in for deeper errors nobody names is replaced by
|
|
693
|
+
* those errors, addressed by data pointer. Errors a hydrated node does name keep their
|
|
694
|
+
* form path, which is what the mutation tools expect.
|
|
695
|
+
* @param {import('../state/index.js').StatefulLayout} statefulLayout
|
|
301
696
|
* @returns {Array<{path: string, message: string}>}
|
|
302
697
|
*/
|
|
303
|
-
export function collectErrors (
|
|
698
|
+
export function collectErrors (statefulLayout) {
|
|
699
|
+
/** @type {Array<{fullKey: string, dataPath: string, message: string}>} */
|
|
700
|
+
const nodeErrors = []
|
|
701
|
+
/** @type {Set<string>} */
|
|
702
|
+
const renderedPointers = new Set()
|
|
703
|
+
/** @param {import('../state/types.js').StateNode} node */
|
|
704
|
+
const recurse = (node) => {
|
|
705
|
+
if (node.error) nodeErrors.push({ fullKey: node.fullKey, dataPath: node.dataPath, message: node.error })
|
|
706
|
+
if (node.skeleton?.pointer) renderedPointers.add(node.skeleton.pointer)
|
|
707
|
+
// all children, not visibleChildren: in "menu"/"dialog" list edit modes the two
|
|
708
|
+
// occurrences of an activated item do not carry the same errors, and deduplicating
|
|
709
|
+
// here silently drops them (verified: 2 errors became 0).
|
|
710
|
+
for (const child of node.children ?? []) recurse(child)
|
|
711
|
+
}
|
|
712
|
+
recurse(statefulLayout.stateTree.root)
|
|
713
|
+
|
|
714
|
+
const named = new Set(nodeErrors.map((e) => e.dataPath))
|
|
715
|
+
const unnamed = statefulLayout.validationErrors
|
|
716
|
+
.filter((error) => isRenderedBranch(error, renderedPointers))
|
|
717
|
+
.map((error) => ({ pointer: dataPointerOf(error), message: error.message ?? 'invalid' }))
|
|
718
|
+
.filter((error) => !named.has(error.pointer))
|
|
719
|
+
|
|
304
720
|
/** @type {Array<{path: string, message: string}>} */
|
|
305
721
|
const errors = []
|
|
306
|
-
|
|
722
|
+
for (const nodeError of nodeErrors) {
|
|
723
|
+
const prefix = nodeError.dataPath === '' ? '/' : `${nodeError.dataPath}/`
|
|
724
|
+
const standsInForDeeperErrors = unnamed.some((e) => e.pointer.startsWith(prefix))
|
|
725
|
+
if (!standsInForDeeperErrors) errors.push({ path: nodeError.fullKey, message: nodeError.message })
|
|
726
|
+
}
|
|
727
|
+
for (const error of unnamed) errors.push({ path: error.pointer, message: error.message })
|
|
307
728
|
return errors
|
|
308
729
|
}
|
|
309
730
|
|
|
310
731
|
/**
|
|
732
|
+
* Errors of the subtree of a node, and count of the errors of the rest of the form.
|
|
733
|
+
* @param {import('../state/index.js').StatefulLayout} statefulLayout
|
|
311
734
|
* @param {import('../state/types.js').StateNode} node
|
|
312
|
-
* @
|
|
735
|
+
* @returns {{ errors: Array<{path: string, message: string}>, otherErrors: number }}
|
|
313
736
|
*/
|
|
314
|
-
function
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
}
|
|
737
|
+
export function collectScopedErrors (statefulLayout, node) {
|
|
738
|
+
// the path index is used rather than a walk of the subtree: an activated list item is kept
|
|
739
|
+
// twice and the tools resolve to the editable occurrence, which carries no error at all,
|
|
740
|
+
// neither on the item nor on anything below it. Indexing by path merges the two.
|
|
741
|
+
const errorsByPath = indexErrorsByPath(statefulLayout.stateTree.root)
|
|
742
|
+
const prefix = node.fullKey
|
|
743
|
+
const isInScope = (/** @type {string} */path) =>
|
|
744
|
+
prefix === '' || path === prefix || path.startsWith(`${prefix}/`)
|
|
745
|
+
|
|
746
|
+
/** @type {Array<{path: string, message: string}>} */
|
|
747
|
+
const errors = []
|
|
748
|
+
let otherErrors = 0
|
|
749
|
+
for (const [path, message] of Object.entries(errorsByPath)) {
|
|
750
|
+
if (isInScope(path)) errors.push({ path, message })
|
|
751
|
+
else otherErrors++
|
|
322
752
|
}
|
|
753
|
+
return { errors, otherErrors }
|
|
323
754
|
}
|