opencode-codeops 2.2.1 → 2.3.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.
- package/CHANGELOG.md +38 -0
- package/README.md +53 -0
- package/_shared/discovery.md +92 -0
- package/_shared/zero-ambiguity-gate.md +4 -0
- package/bin/lib/discovery-rules.d.mts +228 -0
- package/bin/lib/discovery-rules.mjs +1213 -0
- package/bin/lib/discovery-server.d.mts +62 -0
- package/bin/lib/discovery-server.mjs +755 -0
- package/bin/lib/discovery-store.d.mts +224 -0
- package/bin/lib/discovery-store.mjs +1189 -0
- package/bin/lib/discovery-tools.d.mts +66 -0
- package/bin/lib/discovery-tools.mjs +540 -0
- package/discovery-ui/dist/assets/app.js +107 -0
- package/discovery-ui/dist/index.html +17 -0
- package/package.json +14 -2
- package/plugin/index.ts +45 -4
- package/schemas/codeops-config.schema.json +8 -0
- package/scripts/__pycache__/codeops_plan.cpython-314.pyc +0 -0
- package/scripts/__pycache__/install_agents.cpython-314.pyc +0 -0
- package/scripts/build-discovery-ui.mjs +59 -0
- package/skills/analyze-project/SKILL.md +19 -0
- package/skills/make-plan/SKILL.md +7 -1
- package/skills/preflight/SKILL.md +4 -0
- package/skills/preflight/report-format.md +4 -0
- package/scripts/__pycache__/codeops_plan.cpython-312.pyc +0 -0
- package/scripts/__pycache__/install_agents.cpython-312.pyc +0 -0
|
@@ -0,0 +1,1213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discovery rules and model.
|
|
3
|
+
*
|
|
4
|
+
* This module owns the publish contract, answer validation, rule evaluation,
|
|
5
|
+
* submit readiness, and question fingerprinting. The browser bundle and the
|
|
6
|
+
* Node server both import it, so "visible", "implied", "locked", and "ready"
|
|
7
|
+
* mean exactly the same thing on both sides.
|
|
8
|
+
*
|
|
9
|
+
* It is deliberately pure and browser-safe: no `node:` imports, no file
|
|
10
|
+
* system, no clock, no randomness. Every function either returns a value or a
|
|
11
|
+
* documented result object; nothing here throws on malformed input.
|
|
12
|
+
*
|
|
13
|
+
* The data model is JSON-friendly. A plan carries its groups and questions,
|
|
14
|
+
* while the mutable answers and lifecycle live in a separate `PlanState`
|
|
15
|
+
* object so the stored record, the HTTP payload, and the page share one
|
|
16
|
+
* representation.
|
|
17
|
+
*
|
|
18
|
+
* @module discovery-rules
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Schema version written into every Discovery data file. */
|
|
22
|
+
export const DISCOVERY_SCHEMA_VERSION = 1
|
|
23
|
+
|
|
24
|
+
/** Every supported question type. */
|
|
25
|
+
export const QUESTION_TYPES = Object.freeze(["single", "multiple", "text", "single-other"])
|
|
26
|
+
|
|
27
|
+
/** Question types that carry options and therefore accept choice answers. */
|
|
28
|
+
export const CHOICE_TYPES = Object.freeze(["single", "multiple", "single-other"])
|
|
29
|
+
|
|
30
|
+
/** Every supported plan kind. */
|
|
31
|
+
export const PLAN_KINDS = Object.freeze(["plan", "requirements", "preflight"])
|
|
32
|
+
|
|
33
|
+
/** Pattern for question keys, group keys, and option ids. */
|
|
34
|
+
export const KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,39}$/
|
|
35
|
+
|
|
36
|
+
/** Pattern for the lower-case slugs used in URLs and file names. */
|
|
37
|
+
export const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
38
|
+
|
|
39
|
+
/** Maximum length of a plan title, group title, or option label. */
|
|
40
|
+
export const TITLE_MAX = 200
|
|
41
|
+
|
|
42
|
+
/** Maximum length of every other free-text value. */
|
|
43
|
+
export const TEXT_MAX = 4000
|
|
44
|
+
|
|
45
|
+
/** Maximum length of the repository-relative artifact path. */
|
|
46
|
+
export const ARTIFACT_MAX = 300
|
|
47
|
+
|
|
48
|
+
/** Structural caps that keep one publish under the request body limit. */
|
|
49
|
+
export const LIMITS = Object.freeze({
|
|
50
|
+
groups: 50,
|
|
51
|
+
questions: 200,
|
|
52
|
+
optionsMin: 2,
|
|
53
|
+
optionsMax: 20,
|
|
54
|
+
evidence: 20,
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Question fields that make up the fingerprint used to detect a changed
|
|
59
|
+
* question on republish. `group`, `showIf`, and `proposal` are deliberately
|
|
60
|
+
* absent: moving a question between groups or changing how it is revealed
|
|
61
|
+
* does not invalidate a stored answer.
|
|
62
|
+
*/
|
|
63
|
+
export const FINGERPRINT_FIELDS = Object.freeze([
|
|
64
|
+
"type",
|
|
65
|
+
"title",
|
|
66
|
+
"why",
|
|
67
|
+
"evidence",
|
|
68
|
+
"options",
|
|
69
|
+
"recommendation",
|
|
70
|
+
])
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// JSON Schema for the publish tool
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
/** A JSON Schema string leaf with an optional maximum length. */
|
|
77
|
+
function stringSchema(maxLength) {
|
|
78
|
+
return maxLength === undefined
|
|
79
|
+
? { type: "string" }
|
|
80
|
+
: { type: "string", minLength: 1, maxLength }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A compact answer: an option id, a list of option ids, or free text. */
|
|
84
|
+
const COMPACT_ANSWER_SCHEMA = {
|
|
85
|
+
anyOf: [
|
|
86
|
+
{ type: "string" },
|
|
87
|
+
{ type: "array", minItems: 1, items: { type: "string" } },
|
|
88
|
+
],
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** One `implies` entry attached to an option. */
|
|
92
|
+
const IMPLIES_SCHEMA = {
|
|
93
|
+
type: "object",
|
|
94
|
+
additionalProperties: false,
|
|
95
|
+
required: ["question", "answer", "reason"],
|
|
96
|
+
properties: {
|
|
97
|
+
question: stringSchema(),
|
|
98
|
+
answer: COMPACT_ANSWER_SCHEMA,
|
|
99
|
+
reason: { type: "string", minLength: 1, maxLength: TEXT_MAX },
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** One question option. */
|
|
104
|
+
const OPTION_SCHEMA = {
|
|
105
|
+
type: "object",
|
|
106
|
+
additionalProperties: false,
|
|
107
|
+
required: ["id", "label"],
|
|
108
|
+
properties: {
|
|
109
|
+
id: { type: "string", pattern: KEY_PATTERN.source },
|
|
110
|
+
label: { type: "string", minLength: 1, maxLength: TITLE_MAX },
|
|
111
|
+
description: { type: "string", maxLength: TEXT_MAX },
|
|
112
|
+
implies: { type: "array", items: IMPLIES_SCHEMA },
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** One visibility condition. */
|
|
117
|
+
const SHOW_IF_SCHEMA = {
|
|
118
|
+
type: "object",
|
|
119
|
+
additionalProperties: false,
|
|
120
|
+
required: ["question", "anyOf"],
|
|
121
|
+
properties: {
|
|
122
|
+
question: stringSchema(),
|
|
123
|
+
anyOf: { type: "array", minItems: 1, items: { type: "string" } },
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** An agent-proposed settlement for one question. */
|
|
128
|
+
const PROPOSAL_SCHEMA = {
|
|
129
|
+
type: "object",
|
|
130
|
+
additionalProperties: false,
|
|
131
|
+
required: ["answer", "reason"],
|
|
132
|
+
properties: {
|
|
133
|
+
answer: COMPACT_ANSWER_SCHEMA,
|
|
134
|
+
reason: { type: "string", minLength: 1, maxLength: TEXT_MAX },
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** One published group. */
|
|
139
|
+
const GROUP_SCHEMA = {
|
|
140
|
+
type: "object",
|
|
141
|
+
additionalProperties: false,
|
|
142
|
+
required: ["key", "title"],
|
|
143
|
+
properties: {
|
|
144
|
+
key: { type: "string", pattern: KEY_PATTERN.source },
|
|
145
|
+
title: { type: "string", minLength: 1, maxLength: TITLE_MAX },
|
|
146
|
+
description: { type: "string", maxLength: TEXT_MAX },
|
|
147
|
+
dependsOn: { type: "array", items: { type: "string" } },
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** One published question. */
|
|
152
|
+
const QUESTION_SCHEMA = {
|
|
153
|
+
type: "object",
|
|
154
|
+
additionalProperties: false,
|
|
155
|
+
required: ["key", "group", "type", "title"],
|
|
156
|
+
properties: {
|
|
157
|
+
key: { type: "string", pattern: KEY_PATTERN.source },
|
|
158
|
+
group: { type: "string" },
|
|
159
|
+
type: { type: "string", enum: [...QUESTION_TYPES] },
|
|
160
|
+
title: { type: "string", minLength: 1, maxLength: TITLE_MAX },
|
|
161
|
+
why: { type: "string", maxLength: TEXT_MAX },
|
|
162
|
+
evidence: {
|
|
163
|
+
type: "array",
|
|
164
|
+
maxItems: LIMITS.evidence,
|
|
165
|
+
items: { type: "string", maxLength: TEXT_MAX },
|
|
166
|
+
},
|
|
167
|
+
options: {
|
|
168
|
+
type: "array",
|
|
169
|
+
minItems: LIMITS.optionsMin,
|
|
170
|
+
maxItems: LIMITS.optionsMax,
|
|
171
|
+
items: OPTION_SCHEMA,
|
|
172
|
+
},
|
|
173
|
+
recommendation: {
|
|
174
|
+
anyOf: [
|
|
175
|
+
{ type: "string" },
|
|
176
|
+
{ type: "array", minItems: 1, items: { type: "string" } },
|
|
177
|
+
],
|
|
178
|
+
},
|
|
179
|
+
showIf: { type: "array", items: SHOW_IF_SCHEMA },
|
|
180
|
+
proposal: PROPOSAL_SCHEMA,
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* JSON Schema of the `codeops_discovery_publish` payload.
|
|
186
|
+
*
|
|
187
|
+
* Every object level rejects unknown keys, so a typo in a field name is a
|
|
188
|
+
* validation error instead of silently ignored content.
|
|
189
|
+
*/
|
|
190
|
+
export const DiscoveryPublishSchema = {
|
|
191
|
+
type: "object",
|
|
192
|
+
additionalProperties: false,
|
|
193
|
+
required: ["artifact", "kind", "title", "groups", "questions"],
|
|
194
|
+
properties: {
|
|
195
|
+
artifact: { type: "string", minLength: 1, maxLength: ARTIFACT_MAX },
|
|
196
|
+
kind: { type: "string", enum: [...PLAN_KINDS] },
|
|
197
|
+
title: { type: "string", minLength: 1, maxLength: TITLE_MAX },
|
|
198
|
+
takeover: { type: "boolean" },
|
|
199
|
+
groups: {
|
|
200
|
+
type: "array",
|
|
201
|
+
minItems: 1,
|
|
202
|
+
maxItems: LIMITS.groups,
|
|
203
|
+
items: GROUP_SCHEMA,
|
|
204
|
+
},
|
|
205
|
+
questions: {
|
|
206
|
+
type: "array",
|
|
207
|
+
minItems: 1,
|
|
208
|
+
maxItems: LIMITS.questions,
|
|
209
|
+
items: QUESTION_SCHEMA,
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// Small helpers
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
/** True for an object created by `{}` or `JSON.parse`, false for arrays/null. */
|
|
219
|
+
function isPlainObject(value) {
|
|
220
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Trims a value that must be a string, returning "" for any other type. */
|
|
224
|
+
function trimmedString(value) {
|
|
225
|
+
return typeof value === "string" ? value.trim() : ""
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Trims an optional string and reports length violations. */
|
|
229
|
+
function optionalString(value, path, maxLength, fail) {
|
|
230
|
+
if (value === undefined) return undefined
|
|
231
|
+
if (typeof value !== "string") {
|
|
232
|
+
fail(path, "expected a string")
|
|
233
|
+
return undefined
|
|
234
|
+
}
|
|
235
|
+
const trimmed = value.trim()
|
|
236
|
+
if (trimmed.length > maxLength) {
|
|
237
|
+
fail(path, `must be at most ${maxLength} characters`)
|
|
238
|
+
return undefined
|
|
239
|
+
}
|
|
240
|
+
return trimmed.length === 0 ? undefined : trimmed
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Reports fields that the contract does not define. */
|
|
244
|
+
function rejectUnknownKeys(value, allowed, path, fail) {
|
|
245
|
+
for (const key of Object.keys(value)) {
|
|
246
|
+
if (allowed.includes(key)) continue
|
|
247
|
+
fail(path === "" ? `payload.${key}` : `${path}.${key}`, "unknown field")
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Canonical serialization of an answer, used for equality and conflicts. */
|
|
252
|
+
function canonicalAnswer(answer) {
|
|
253
|
+
if (answer === null || answer === undefined) return "null"
|
|
254
|
+
if (Array.isArray(answer.choices)) {
|
|
255
|
+
return JSON.stringify({ choices: [...answer.choices].sort() })
|
|
256
|
+
}
|
|
257
|
+
if (typeof answer.choice === "string") return JSON.stringify({ choice: answer.choice })
|
|
258
|
+
if (typeof answer.text === "string") return JSON.stringify({ text: answer.text })
|
|
259
|
+
if (typeof answer.other === "string") return JSON.stringify({ other: answer.other })
|
|
260
|
+
return JSON.stringify(answer)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** True when two answers carry the same value. */
|
|
264
|
+
function sameAnswer(left, right) {
|
|
265
|
+
return canonicalAnswer(left) === canonicalAnswer(right)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Option ids selected by a stored answer, ignoring free text. */
|
|
269
|
+
function selectedOptionIds(question, answer) {
|
|
270
|
+
if (!answer) return []
|
|
271
|
+
if (question.type === "multiple") {
|
|
272
|
+
return Array.isArray(answer.choices) ? [...answer.choices] : []
|
|
273
|
+
}
|
|
274
|
+
if (question.type === "single" || question.type === "single-other") {
|
|
275
|
+
return typeof answer.choice === "string" ? [answer.choice] : []
|
|
276
|
+
}
|
|
277
|
+
return []
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Trims a compact answer's string parts without changing its shape. */
|
|
281
|
+
function normalizeCompactAnswer(compact) {
|
|
282
|
+
if (typeof compact === "string") return compact.trim()
|
|
283
|
+
if (Array.isArray(compact)) {
|
|
284
|
+
return compact.map((value) => (typeof value === "string" ? value.trim() : value))
|
|
285
|
+
}
|
|
286
|
+
return compact
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Validates a repository-relative POSIX artifact path. */
|
|
290
|
+
function validateArtifact(artifact) {
|
|
291
|
+
if (artifact.length === 0) return "is required"
|
|
292
|
+
if (artifact.length > ARTIFACT_MAX) return `must be at most ${ARTIFACT_MAX} characters`
|
|
293
|
+
if (artifact.startsWith("/")) return "must be repository-relative"
|
|
294
|
+
if (artifact.includes("\\")) return "must use POSIX separators"
|
|
295
|
+
const segments = artifact.split("/")
|
|
296
|
+
if (segments.some((segment) => segment.length === 0)) return "must not contain empty segments"
|
|
297
|
+
if (segments.includes("..")) return "must not contain a parent segment"
|
|
298
|
+
return null
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
// Publish validation
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Validate and normalize a publish payload.
|
|
307
|
+
*
|
|
308
|
+
* The function never throws. It collects every structural problem first, then
|
|
309
|
+
* checks cross-references and cycles, and finally returns a normalized copy
|
|
310
|
+
* with trimmed strings and dropped empty optional values.
|
|
311
|
+
*
|
|
312
|
+
* @param {unknown} input - The raw tool input.
|
|
313
|
+
* @returns {{ ok: true, plan: object } | { ok: false, errors: string[] }}
|
|
314
|
+
* The normalized plan, or one error per problem with its field path.
|
|
315
|
+
*/
|
|
316
|
+
export function validatePublish(input) {
|
|
317
|
+
const errors = []
|
|
318
|
+
const fail = (path, message) => errors.push(`${path}: ${message}`)
|
|
319
|
+
if (!isPlainObject(input)) return { ok: false, errors: ["payload: expected an object"] }
|
|
320
|
+
|
|
321
|
+
rejectUnknownKeys(
|
|
322
|
+
input,
|
|
323
|
+
["artifact", "kind", "title", "takeover", "groups", "questions"],
|
|
324
|
+
"",
|
|
325
|
+
fail,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
const artifact = trimmedString(input.artifact)
|
|
329
|
+
const artifactError = validateArtifact(artifact)
|
|
330
|
+
if (artifactError !== null) fail("artifact", artifactError)
|
|
331
|
+
|
|
332
|
+
if (!PLAN_KINDS.includes(input.kind)) {
|
|
333
|
+
fail("kind", `expected one of ${PLAN_KINDS.join(", ")}`)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
let title = trimmedString(input.title)
|
|
337
|
+
if (title.length === 0) fail("title", "is required")
|
|
338
|
+
else if (title.length > TITLE_MAX) {
|
|
339
|
+
fail("title", `must be at most ${TITLE_MAX} characters`)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if ("takeover" in input && typeof input.takeover !== "boolean") {
|
|
343
|
+
fail("takeover", "expected a boolean")
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const groups = []
|
|
347
|
+
const seenGroupKeys = new Set()
|
|
348
|
+
if (!Array.isArray(input.groups)) {
|
|
349
|
+
fail("groups", "expected an array")
|
|
350
|
+
} else {
|
|
351
|
+
if (input.groups.length < 1 || input.groups.length > LIMITS.groups) {
|
|
352
|
+
fail("groups", `must hold 1 to ${LIMITS.groups} entries`)
|
|
353
|
+
}
|
|
354
|
+
input.groups.forEach((raw, index) => {
|
|
355
|
+
const path = `groups[${index}]`
|
|
356
|
+
if (!isPlainObject(raw)) {
|
|
357
|
+
fail(path, "expected an object")
|
|
358
|
+
return
|
|
359
|
+
}
|
|
360
|
+
rejectUnknownKeys(raw, ["key", "title", "description", "dependsOn"], path, fail)
|
|
361
|
+
const key = trimmedString(raw.key)
|
|
362
|
+
if (!KEY_PATTERN.test(key)) fail(`${path}.key`, "must be a key of 1 to 40 characters")
|
|
363
|
+
else if (seenGroupKeys.has(key)) fail(`${path}.key`, `duplicate group key "${key}"`)
|
|
364
|
+
else seenGroupKeys.add(key)
|
|
365
|
+
const groupTitle = trimmedString(raw.title)
|
|
366
|
+
if (groupTitle.length === 0) fail(`${path}.title`, "is required")
|
|
367
|
+
else if (groupTitle.length > TITLE_MAX) {
|
|
368
|
+
fail(`${path}.title`, `must be at most ${TITLE_MAX} characters`)
|
|
369
|
+
}
|
|
370
|
+
const description = optionalString(raw.description, `${path}.description`, TEXT_MAX, fail)
|
|
371
|
+
const dependsOn = []
|
|
372
|
+
if (raw.dependsOn !== undefined) {
|
|
373
|
+
if (!Array.isArray(raw.dependsOn)) fail(`${path}.dependsOn`, "expected an array")
|
|
374
|
+
else {
|
|
375
|
+
raw.dependsOn.forEach((value, position) => {
|
|
376
|
+
const target = trimmedString(value)
|
|
377
|
+
if (!KEY_PATTERN.test(target)) {
|
|
378
|
+
fail(`${path}.dependsOn[${position}]`, "expected a group key")
|
|
379
|
+
return
|
|
380
|
+
}
|
|
381
|
+
dependsOn.push(target)
|
|
382
|
+
})
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
groups.push({
|
|
386
|
+
key,
|
|
387
|
+
title: groupTitle,
|
|
388
|
+
...(description === undefined ? {} : { description }),
|
|
389
|
+
dependsOn,
|
|
390
|
+
})
|
|
391
|
+
})
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const questions = []
|
|
395
|
+
const questionPaths = []
|
|
396
|
+
const questionByKey = new Map()
|
|
397
|
+
if (!Array.isArray(input.questions)) {
|
|
398
|
+
fail("questions", "expected an array")
|
|
399
|
+
} else {
|
|
400
|
+
if (input.questions.length < 1 || input.questions.length > LIMITS.questions) {
|
|
401
|
+
fail("questions", `must hold 1 to ${LIMITS.questions} entries`)
|
|
402
|
+
}
|
|
403
|
+
input.questions.forEach((raw, index) => {
|
|
404
|
+
const path = `questions[${index}]`
|
|
405
|
+
questionPaths[index] = path
|
|
406
|
+
if (!isPlainObject(raw)) {
|
|
407
|
+
fail(path, "expected an object")
|
|
408
|
+
return
|
|
409
|
+
}
|
|
410
|
+
rejectUnknownKeys(
|
|
411
|
+
raw,
|
|
412
|
+
[
|
|
413
|
+
"key",
|
|
414
|
+
"group",
|
|
415
|
+
"type",
|
|
416
|
+
"title",
|
|
417
|
+
"why",
|
|
418
|
+
"evidence",
|
|
419
|
+
"options",
|
|
420
|
+
"recommendation",
|
|
421
|
+
"showIf",
|
|
422
|
+
"proposal",
|
|
423
|
+
],
|
|
424
|
+
path,
|
|
425
|
+
fail,
|
|
426
|
+
)
|
|
427
|
+
const key = trimmedString(raw.key)
|
|
428
|
+
if (!KEY_PATTERN.test(key)) fail(`${path}.key`, "must be a key of 1 to 40 characters")
|
|
429
|
+
else if (questionByKey.has(key)) fail(`${path}.key`, `duplicate question key "${key}"`)
|
|
430
|
+
const group = trimmedString(raw.group)
|
|
431
|
+
if (group.length === 0) fail(`${path}.group`, "is required")
|
|
432
|
+
const type = raw.type
|
|
433
|
+
if (!QUESTION_TYPES.includes(type)) {
|
|
434
|
+
fail(`${path}.type`, `expected one of ${QUESTION_TYPES.join(", ")}`)
|
|
435
|
+
}
|
|
436
|
+
const questionTitle = trimmedString(raw.title)
|
|
437
|
+
if (questionTitle.length === 0) fail(`${path}.title`, "is required")
|
|
438
|
+
else if (questionTitle.length > TITLE_MAX) {
|
|
439
|
+
fail(`${path}.title`, `must be at most ${TITLE_MAX} characters`)
|
|
440
|
+
}
|
|
441
|
+
const why = optionalString(raw.why, `${path}.why`, TEXT_MAX, fail)
|
|
442
|
+
|
|
443
|
+
let evidence
|
|
444
|
+
if (raw.evidence !== undefined) {
|
|
445
|
+
if (!Array.isArray(raw.evidence)) fail(`${path}.evidence`, "expected an array")
|
|
446
|
+
else {
|
|
447
|
+
if (raw.evidence.length > LIMITS.evidence) {
|
|
448
|
+
fail(`${path}.evidence`, `must hold at most ${LIMITS.evidence} items`)
|
|
449
|
+
}
|
|
450
|
+
evidence = []
|
|
451
|
+
raw.evidence.forEach((value, position) => {
|
|
452
|
+
if (typeof value !== "string") {
|
|
453
|
+
fail(`${path}.evidence[${position}]`, "expected a string")
|
|
454
|
+
return
|
|
455
|
+
}
|
|
456
|
+
const trimmed = value.trim()
|
|
457
|
+
if (trimmed.length > TEXT_MAX) {
|
|
458
|
+
fail(`${path}.evidence[${position}]`, `must be at most ${TEXT_MAX} characters`)
|
|
459
|
+
return
|
|
460
|
+
}
|
|
461
|
+
if (trimmed.length > 0) evidence.push(trimmed)
|
|
462
|
+
})
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
let options
|
|
467
|
+
if (type === "text") {
|
|
468
|
+
if (raw.options !== undefined) fail(`${path}.options`, "a text question has no options")
|
|
469
|
+
} else if (CHOICE_TYPES.includes(type)) {
|
|
470
|
+
if (!Array.isArray(raw.options)) {
|
|
471
|
+
fail(`${path}.options`, "expected an array")
|
|
472
|
+
} else {
|
|
473
|
+
if (
|
|
474
|
+
raw.options.length < LIMITS.optionsMin ||
|
|
475
|
+
raw.options.length > LIMITS.optionsMax
|
|
476
|
+
) {
|
|
477
|
+
fail(`${path}.options`, `must hold ${LIMITS.optionsMin} to ${LIMITS.optionsMax} options`)
|
|
478
|
+
}
|
|
479
|
+
options = []
|
|
480
|
+
const seenIds = new Set()
|
|
481
|
+
raw.options.forEach((rawOption, position) => {
|
|
482
|
+
const optionPath = `${path}.options[${position}]`
|
|
483
|
+
if (!isPlainObject(rawOption)) {
|
|
484
|
+
fail(optionPath, "expected an object")
|
|
485
|
+
return
|
|
486
|
+
}
|
|
487
|
+
rejectUnknownKeys(rawOption, ["id", "label", "description", "implies"], optionPath, fail)
|
|
488
|
+
const id = trimmedString(rawOption.id)
|
|
489
|
+
if (!KEY_PATTERN.test(id)) {
|
|
490
|
+
fail(`${optionPath}.id`, "must be an option id of 1 to 40 characters")
|
|
491
|
+
} else if (seenIds.has(id)) {
|
|
492
|
+
fail(`${optionPath}.id`, `duplicate option id "${id}"`)
|
|
493
|
+
}
|
|
494
|
+
seenIds.add(id)
|
|
495
|
+
const label = trimmedString(rawOption.label)
|
|
496
|
+
if (label.length === 0) fail(`${optionPath}.label`, "is required")
|
|
497
|
+
else if (label.length > TITLE_MAX) {
|
|
498
|
+
fail(`${optionPath}.label`, `must be at most ${TITLE_MAX} characters`)
|
|
499
|
+
}
|
|
500
|
+
const description = optionalString(
|
|
501
|
+
rawOption.description,
|
|
502
|
+
`${optionPath}.description`,
|
|
503
|
+
TEXT_MAX,
|
|
504
|
+
fail,
|
|
505
|
+
)
|
|
506
|
+
if (rawOption.implies !== undefined && !Array.isArray(rawOption.implies)) {
|
|
507
|
+
fail(`${optionPath}.implies`, "expected an array")
|
|
508
|
+
}
|
|
509
|
+
options.push({
|
|
510
|
+
id,
|
|
511
|
+
label,
|
|
512
|
+
...(description === undefined ? {} : { description }),
|
|
513
|
+
...(Array.isArray(rawOption.implies) ? { implies: rawOption.implies } : {}),
|
|
514
|
+
})
|
|
515
|
+
})
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
let recommendation
|
|
520
|
+
if (raw.recommendation !== undefined) {
|
|
521
|
+
if (typeof raw.recommendation === "string") {
|
|
522
|
+
const trimmed = raw.recommendation.trim()
|
|
523
|
+
if (trimmed.length === 0 || trimmed.length > TEXT_MAX) {
|
|
524
|
+
fail(`${path}.recommendation`, "must be a non-empty string")
|
|
525
|
+
} else {
|
|
526
|
+
recommendation = trimmed
|
|
527
|
+
}
|
|
528
|
+
} else if (Array.isArray(raw.recommendation)) {
|
|
529
|
+
if (raw.recommendation.length === 0) {
|
|
530
|
+
fail(`${path}.recommendation`, "must not be empty")
|
|
531
|
+
} else {
|
|
532
|
+
recommendation = []
|
|
533
|
+
raw.recommendation.forEach((value, position) => {
|
|
534
|
+
const trimmed = trimmedString(value)
|
|
535
|
+
if (trimmed.length === 0) {
|
|
536
|
+
fail(`${path}.recommendation[${position}]`, "must not be empty")
|
|
537
|
+
return
|
|
538
|
+
}
|
|
539
|
+
recommendation.push(trimmed)
|
|
540
|
+
})
|
|
541
|
+
}
|
|
542
|
+
} else {
|
|
543
|
+
fail(`${path}.recommendation`, "expected a string or an array of strings")
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
let showIf
|
|
548
|
+
if (raw.showIf !== undefined) {
|
|
549
|
+
if (!Array.isArray(raw.showIf)) fail(`${path}.showIf`, "expected an array")
|
|
550
|
+
else {
|
|
551
|
+
showIf = []
|
|
552
|
+
raw.showIf.forEach((condition, position) => {
|
|
553
|
+
const conditionPath = `${path}.showIf[${position}]`
|
|
554
|
+
if (!isPlainObject(condition)) {
|
|
555
|
+
fail(conditionPath, "expected an object")
|
|
556
|
+
return
|
|
557
|
+
}
|
|
558
|
+
rejectUnknownKeys(condition, ["question", "anyOf"], conditionPath, fail)
|
|
559
|
+
const reference = trimmedString(condition.question)
|
|
560
|
+
if (reference.length === 0) fail(`${conditionPath}.question`, "is required")
|
|
561
|
+
const anyOf = []
|
|
562
|
+
if (!Array.isArray(condition.anyOf) || condition.anyOf.length === 0) {
|
|
563
|
+
fail(`${conditionPath}.anyOf`, "expected a non-empty array of option ids")
|
|
564
|
+
} else {
|
|
565
|
+
condition.anyOf.forEach((value, idPosition) => {
|
|
566
|
+
const id = trimmedString(value)
|
|
567
|
+
if (!KEY_PATTERN.test(id)) {
|
|
568
|
+
fail(`${conditionPath}.anyOf[${idPosition}]`, "expected an option id")
|
|
569
|
+
return
|
|
570
|
+
}
|
|
571
|
+
anyOf.push(id)
|
|
572
|
+
})
|
|
573
|
+
}
|
|
574
|
+
showIf.push({ question: reference, anyOf })
|
|
575
|
+
})
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const normalized = {
|
|
580
|
+
key,
|
|
581
|
+
group,
|
|
582
|
+
type,
|
|
583
|
+
title: questionTitle,
|
|
584
|
+
...(why === undefined ? {} : { why }),
|
|
585
|
+
...(evidence === undefined ? {} : { evidence }),
|
|
586
|
+
...(options === undefined ? {} : { options }),
|
|
587
|
+
...(recommendation === undefined ? {} : { recommendation }),
|
|
588
|
+
...(showIf === undefined ? {} : { showIf }),
|
|
589
|
+
...(raw.proposal === undefined ? {} : { proposal: raw.proposal }),
|
|
590
|
+
}
|
|
591
|
+
if (key.length > 0 && !questionByKey.has(key)) questionByKey.set(key, normalized)
|
|
592
|
+
questions.push(normalized)
|
|
593
|
+
})
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Structural problems make the cross-reference pass meaningless.
|
|
597
|
+
if (errors.length > 0) return { ok: false, errors }
|
|
598
|
+
|
|
599
|
+
const groupKeys = new Set(groups.map((group) => group.key))
|
|
600
|
+
for (const [index, group] of groups.entries()) {
|
|
601
|
+
for (const [position, dependency] of group.dependsOn.entries()) {
|
|
602
|
+
if (!groupKeys.has(dependency)) {
|
|
603
|
+
fail(`groups[${index}].dependsOn[${position}]`, `unknown group "${dependency}"`)
|
|
604
|
+
}
|
|
605
|
+
if (dependency === group.key) {
|
|
606
|
+
fail(`groups[${index}].dependsOn[${position}]`, "a group cannot depend on itself")
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
for (const [index, question] of questions.entries()) {
|
|
612
|
+
const path = questionPaths[index]
|
|
613
|
+
if (!groupKeys.has(question.group)) {
|
|
614
|
+
fail(`${path}.group`, `unknown group "${question.group}"`)
|
|
615
|
+
}
|
|
616
|
+
for (const [position, condition] of (question.showIf ?? []).entries()) {
|
|
617
|
+
const conditionPath = `${path}.showIf[${position}]`
|
|
618
|
+
const target = questionByKey.get(condition.question)
|
|
619
|
+
if (!target) {
|
|
620
|
+
fail(`${conditionPath}.question`, `unknown question "${condition.question}"`)
|
|
621
|
+
continue
|
|
622
|
+
}
|
|
623
|
+
if (target.key === question.key) {
|
|
624
|
+
fail(`${conditionPath}.question`, "a question cannot depend on itself")
|
|
625
|
+
continue
|
|
626
|
+
}
|
|
627
|
+
if (!CHOICE_TYPES.includes(target.type)) {
|
|
628
|
+
fail(`${conditionPath}.question`, "the referenced question must be a choice question")
|
|
629
|
+
continue
|
|
630
|
+
}
|
|
631
|
+
for (const [idPosition, id] of condition.anyOf.entries()) {
|
|
632
|
+
if (!optionIdsOf(target).includes(id)) {
|
|
633
|
+
fail(`${conditionPath}.anyOf[${idPosition}]`, `unknown option "${id}"`)
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
for (const [optionPosition, option] of (question.options ?? []).entries()) {
|
|
638
|
+
for (const [implyPosition, implication] of (option.implies ?? []).entries()) {
|
|
639
|
+
const implyPath = `${path}.options[${optionPosition}].implies[${implyPosition}]`
|
|
640
|
+
validateImplication(implication, implyPath, questionByKey, question, fail)
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
if (question.proposal !== undefined) {
|
|
644
|
+
validateProposal(question, question.proposal, `${path}.proposal`, fail)
|
|
645
|
+
}
|
|
646
|
+
validateRecommendation(question, `${path}.recommendation`, fail)
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (errors.length > 0) return { ok: false, errors }
|
|
650
|
+
|
|
651
|
+
checkAcyclic(
|
|
652
|
+
groups.map((group) => [group.key, group.dependsOn]),
|
|
653
|
+
(cycle) => fail("groups", `dependency cycle ${cycle.join(" -> ")}`),
|
|
654
|
+
)
|
|
655
|
+
checkAcyclic(
|
|
656
|
+
questions
|
|
657
|
+
.filter((question) => (question.showIf ?? []).length > 0)
|
|
658
|
+
.map((question) => [question.key, question.showIf.map((condition) => condition.question)]),
|
|
659
|
+
(cycle) => fail("questions", `showIf cycle ${cycle.join(" -> ")}`),
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
if (errors.length > 0) return { ok: false, errors }
|
|
663
|
+
|
|
664
|
+
const normalizedQuestions = questions.map((question) => ({
|
|
665
|
+
...question,
|
|
666
|
+
...(question.options === undefined
|
|
667
|
+
? {}
|
|
668
|
+
: {
|
|
669
|
+
options: question.options.map((option) => ({
|
|
670
|
+
...option,
|
|
671
|
+
...(option.implies === undefined
|
|
672
|
+
? {}
|
|
673
|
+
: {
|
|
674
|
+
implies: option.implies.map((implication) => ({
|
|
675
|
+
question: implication.question.trim(),
|
|
676
|
+
answer: normalizeCompactAnswer(implication.answer),
|
|
677
|
+
reason: implication.reason.trim(),
|
|
678
|
+
})),
|
|
679
|
+
}),
|
|
680
|
+
})),
|
|
681
|
+
}),
|
|
682
|
+
...(question.proposal === undefined
|
|
683
|
+
? {}
|
|
684
|
+
: {
|
|
685
|
+
proposal: {
|
|
686
|
+
answer: normalizeCompactAnswer(question.proposal.answer),
|
|
687
|
+
reason: question.proposal.reason.trim(),
|
|
688
|
+
},
|
|
689
|
+
}),
|
|
690
|
+
}))
|
|
691
|
+
|
|
692
|
+
return {
|
|
693
|
+
ok: true,
|
|
694
|
+
plan: {
|
|
695
|
+
artifact,
|
|
696
|
+
kind: input.kind,
|
|
697
|
+
title,
|
|
698
|
+
groups,
|
|
699
|
+
questions: normalizedQuestions,
|
|
700
|
+
},
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/** Option ids of a choice question. */
|
|
705
|
+
function optionIdsOf(question) {
|
|
706
|
+
return (question.options ?? []).map((option) => option.id)
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/** Validate one `implies` entry against the payload's questions. */
|
|
710
|
+
function validateImplication(implication, path, questionByKey, owner, fail) {
|
|
711
|
+
if (!isPlainObject(implication)) {
|
|
712
|
+
fail(path, "expected an object")
|
|
713
|
+
return
|
|
714
|
+
}
|
|
715
|
+
rejectUnknownKeys(implication, ["question", "answer", "reason"], path, fail)
|
|
716
|
+
const reference = trimmedString(implication.question)
|
|
717
|
+
if (reference.length === 0) {
|
|
718
|
+
fail(`${path}.question`, "is required")
|
|
719
|
+
return
|
|
720
|
+
}
|
|
721
|
+
const target = questionByKey.get(reference)
|
|
722
|
+
if (!target) {
|
|
723
|
+
fail(`${path}.question`, `unknown question "${reference}"`)
|
|
724
|
+
return
|
|
725
|
+
}
|
|
726
|
+
if (target.key === owner.key) {
|
|
727
|
+
fail(`${path}.question`, "a question cannot imply itself")
|
|
728
|
+
return
|
|
729
|
+
}
|
|
730
|
+
const reason = trimmedString(implication.reason)
|
|
731
|
+
if (reason.length === 0) fail(`${path}.reason`, "is required")
|
|
732
|
+
else if (reason.length > TEXT_MAX) {
|
|
733
|
+
fail(`${path}.reason`, `must be at most ${TEXT_MAX} characters`)
|
|
734
|
+
}
|
|
735
|
+
const compact = validateCompactAnswer(target, implication.answer)
|
|
736
|
+
if (!compact.ok) fail(`${path}.answer`, compact.error)
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/** Validate a proposal's answer against its own question. */
|
|
740
|
+
function validateProposal(question, proposal, path, fail) {
|
|
741
|
+
if (!isPlainObject(proposal)) {
|
|
742
|
+
fail(path, "expected an object")
|
|
743
|
+
return
|
|
744
|
+
}
|
|
745
|
+
rejectUnknownKeys(proposal, ["answer", "reason"], path, fail)
|
|
746
|
+
const reason = trimmedString(proposal.reason)
|
|
747
|
+
if (reason.length === 0) fail(`${path}.reason`, "is required")
|
|
748
|
+
else if (reason.length > TEXT_MAX) {
|
|
749
|
+
fail(`${path}.reason`, `must be at most ${TEXT_MAX} characters`)
|
|
750
|
+
}
|
|
751
|
+
const compact = validateCompactAnswer(question, proposal.answer)
|
|
752
|
+
if (!compact.ok) fail(`${path}.answer`, compact.error)
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/** Validate a recommendation against its question type. */
|
|
756
|
+
function validateRecommendation(question, path, fail) {
|
|
757
|
+
if (question.recommendation === undefined) return
|
|
758
|
+
const ids = optionIdsOf(question)
|
|
759
|
+
if (question.type === "text") {
|
|
760
|
+
if (typeof question.recommendation !== "string" || question.recommendation.length === 0) {
|
|
761
|
+
fail(path, "a text question recommends a non-empty string")
|
|
762
|
+
}
|
|
763
|
+
return
|
|
764
|
+
}
|
|
765
|
+
if (question.type === "multiple") {
|
|
766
|
+
if (!Array.isArray(question.recommendation)) {
|
|
767
|
+
fail(path, "a multiple question recommends an array of option ids")
|
|
768
|
+
return
|
|
769
|
+
}
|
|
770
|
+
for (const id of question.recommendation) {
|
|
771
|
+
if (!ids.includes(id)) fail(path, `unknown option "${id}"`)
|
|
772
|
+
}
|
|
773
|
+
return
|
|
774
|
+
}
|
|
775
|
+
if (typeof question.recommendation !== "string") {
|
|
776
|
+
fail(path, "a single question recommends one option id")
|
|
777
|
+
return
|
|
778
|
+
}
|
|
779
|
+
if (!ids.includes(question.recommendation)) {
|
|
780
|
+
fail(path, `unknown option "${question.recommendation}"`)
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/** Validate a compact answer (option id, ids, or text) for a question. */
|
|
785
|
+
function validateCompactAnswer(question, compact) {
|
|
786
|
+
const ids = optionIdsOf(question)
|
|
787
|
+
if (question.type === "multiple") {
|
|
788
|
+
if (!Array.isArray(compact) || compact.length === 0) {
|
|
789
|
+
return { ok: false, error: "expected a non-empty array of option ids" }
|
|
790
|
+
}
|
|
791
|
+
for (const value of compact) {
|
|
792
|
+
if (typeof value !== "string" || !ids.includes(value)) {
|
|
793
|
+
return { ok: false, error: `unknown option "${String(value)}"` }
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
return { ok: true }
|
|
797
|
+
}
|
|
798
|
+
if (question.type === "text") {
|
|
799
|
+
if (typeof compact !== "string" || compact.trim().length === 0) {
|
|
800
|
+
return { ok: false, error: "expected non-empty text" }
|
|
801
|
+
}
|
|
802
|
+
if (compact.trim().length > TEXT_MAX) {
|
|
803
|
+
return { ok: false, error: `must be at most ${TEXT_MAX} characters` }
|
|
804
|
+
}
|
|
805
|
+
return { ok: true }
|
|
806
|
+
}
|
|
807
|
+
if (typeof compact !== "string" || !ids.includes(compact)) {
|
|
808
|
+
return { ok: false, error: `unknown option "${String(compact)}"` }
|
|
809
|
+
}
|
|
810
|
+
return { ok: true }
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/** Detect a cycle in a directed graph and report the first one found. */
|
|
814
|
+
function checkAcyclic(edges, onCycle) {
|
|
815
|
+
const state = new Map()
|
|
816
|
+
const stack = []
|
|
817
|
+
let reported = false
|
|
818
|
+
|
|
819
|
+
function visit(node) {
|
|
820
|
+
if (reported) return
|
|
821
|
+
const current = state.get(node) ?? 0
|
|
822
|
+
if (current === 1) {
|
|
823
|
+
const start = stack.indexOf(node)
|
|
824
|
+
onCycle(stack.slice(start).concat(node))
|
|
825
|
+
reported = true
|
|
826
|
+
return
|
|
827
|
+
}
|
|
828
|
+
if (current === 2) return
|
|
829
|
+
state.set(node, 1)
|
|
830
|
+
stack.push(node)
|
|
831
|
+
const targets = edges.find(([source]) => source === node)?.[1] ?? []
|
|
832
|
+
for (const target of targets) visit(target)
|
|
833
|
+
stack.pop()
|
|
834
|
+
state.set(node, 2)
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
for (const [node] of edges) visit(node)
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// ---------------------------------------------------------------------------
|
|
841
|
+
// Answers and deferrals
|
|
842
|
+
// ---------------------------------------------------------------------------
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* Validate one stored answer for its question type.
|
|
846
|
+
*
|
|
847
|
+
* Exactly one of `choice`, `choices`, `text`, or `other` is allowed, except
|
|
848
|
+
* that a `single-other` question accepts `choice` or `other` — never both.
|
|
849
|
+
*
|
|
850
|
+
* @param {object} question - A normalized question.
|
|
851
|
+
* @param {unknown} answer - The candidate stored answer.
|
|
852
|
+
* @returns {{ ok: true } | { ok: false, error: string }}
|
|
853
|
+
*/
|
|
854
|
+
export function validateAnswer(question, answer) {
|
|
855
|
+
if (!isPlainObject(answer)) return { ok: false, error: "expected an object" }
|
|
856
|
+
const ids = optionIdsOf(question)
|
|
857
|
+
const fields = Object.keys(answer)
|
|
858
|
+
|
|
859
|
+
if (question.type === "single") {
|
|
860
|
+
if (fields.length !== 1 || !("choice" in answer)) {
|
|
861
|
+
return { ok: false, error: "a single question accepts exactly one choice" }
|
|
862
|
+
}
|
|
863
|
+
if (typeof answer.choice !== "string" || !ids.includes(answer.choice)) {
|
|
864
|
+
return { ok: false, error: `unknown option "${String(answer.choice)}"` }
|
|
865
|
+
}
|
|
866
|
+
return { ok: true }
|
|
867
|
+
}
|
|
868
|
+
if (question.type === "single-other") {
|
|
869
|
+
if (fields.length !== 1 || (!("choice" in answer) && !("other" in answer))) {
|
|
870
|
+
return { ok: false, error: "a single-other question accepts a choice or free text, not both" }
|
|
871
|
+
}
|
|
872
|
+
if ("choice" in answer) {
|
|
873
|
+
if (typeof answer.choice !== "string" || !ids.includes(answer.choice)) {
|
|
874
|
+
return { ok: false, error: `unknown option "${String(answer.choice)}"` }
|
|
875
|
+
}
|
|
876
|
+
return { ok: true }
|
|
877
|
+
}
|
|
878
|
+
const other = trimmedString(answer.other)
|
|
879
|
+
if (other.length === 0) return { ok: false, error: "free text must not be empty" }
|
|
880
|
+
if (other.length > TEXT_MAX) {
|
|
881
|
+
return { ok: false, error: `free text must be at most ${TEXT_MAX} characters` }
|
|
882
|
+
}
|
|
883
|
+
return { ok: true }
|
|
884
|
+
}
|
|
885
|
+
if (question.type === "multiple") {
|
|
886
|
+
if (fields.length !== 1 || !("choices" in answer)) {
|
|
887
|
+
return { ok: false, error: "a multiple question accepts exactly one choices list" }
|
|
888
|
+
}
|
|
889
|
+
if (!Array.isArray(answer.choices) || answer.choices.length === 0) {
|
|
890
|
+
return { ok: false, error: "choose at least one option" }
|
|
891
|
+
}
|
|
892
|
+
const seen = new Set()
|
|
893
|
+
for (const value of answer.choices) {
|
|
894
|
+
if (typeof value !== "string" || !ids.includes(value)) {
|
|
895
|
+
return { ok: false, error: `unknown option "${String(value)}"` }
|
|
896
|
+
}
|
|
897
|
+
if (seen.has(value)) return { ok: false, error: `repeated option "${value}"` }
|
|
898
|
+
seen.add(value)
|
|
899
|
+
}
|
|
900
|
+
return { ok: true }
|
|
901
|
+
}
|
|
902
|
+
if (question.type === "text") {
|
|
903
|
+
if (fields.length !== 1 || !("text" in answer)) {
|
|
904
|
+
return { ok: false, error: "a text question accepts exactly one text value" }
|
|
905
|
+
}
|
|
906
|
+
const value = trimmedString(answer.text)
|
|
907
|
+
if (value.length === 0) return { ok: false, error: "text must not be empty" }
|
|
908
|
+
if (value.length > TEXT_MAX) {
|
|
909
|
+
return { ok: false, error: `text must be at most ${TEXT_MAX} characters` }
|
|
910
|
+
}
|
|
911
|
+
return { ok: true }
|
|
912
|
+
}
|
|
913
|
+
return { ok: false, error: `unsupported question type "${String(question.type)}"` }
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Validate a deferral: decision, owner, and revisit must all be non-empty.
|
|
918
|
+
*
|
|
919
|
+
* @param {unknown} defer - The candidate deferral.
|
|
920
|
+
* @returns {{ ok: true } | { ok: false, error: string }}
|
|
921
|
+
*/
|
|
922
|
+
export function validateDefer(defer) {
|
|
923
|
+
if (!isPlainObject(defer)) return { ok: false, error: "expected an object" }
|
|
924
|
+
for (const field of ["decision", "owner", "revisit"]) {
|
|
925
|
+
const value = trimmedString(defer[field])
|
|
926
|
+
if (value.length === 0) return { ok: false, error: `${field} is required` }
|
|
927
|
+
if (value.length > TEXT_MAX) {
|
|
928
|
+
return { ok: false, error: `${field} must be at most ${TEXT_MAX} characters` }
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
return { ok: true }
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* Convert a compact answer (option id, ids, or text) into the stored shape.
|
|
936
|
+
*
|
|
937
|
+
* @param {object} question - The target question.
|
|
938
|
+
* @param {unknown} compact - A validated compact answer.
|
|
939
|
+
* @returns {object} `{ choice }`, `{ choices }`, or `{ text }`.
|
|
940
|
+
*/
|
|
941
|
+
export function toAnswer(question, compact) {
|
|
942
|
+
if (question.type === "multiple") return { choices: [...compact] }
|
|
943
|
+
if (question.type === "text") return { text: String(compact).trim() }
|
|
944
|
+
return { choice: compact }
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// ---------------------------------------------------------------------------
|
|
948
|
+
// Fingerprint
|
|
949
|
+
// ---------------------------------------------------------------------------
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* Canonical content string used to detect a changed question on republish.
|
|
953
|
+
*
|
|
954
|
+
* Two questions with the same fingerprint keep the user's answer untouched.
|
|
955
|
+
* Group membership, visibility rules, and the agent's proposal are excluded so
|
|
956
|
+
* moving or re-revealing a question never invalidates an answer.
|
|
957
|
+
*
|
|
958
|
+
* @param {object} question - A normalized question.
|
|
959
|
+
* @returns {string} A deterministic serialization of the content fields.
|
|
960
|
+
*/
|
|
961
|
+
export function questionFingerprint(question) {
|
|
962
|
+
const canonical = {}
|
|
963
|
+
for (const field of FINGERPRINT_FIELDS) {
|
|
964
|
+
if (field === "options") {
|
|
965
|
+
canonical.options = (question.options ?? []).map((option) => ({
|
|
966
|
+
id: option.id,
|
|
967
|
+
label: option.label,
|
|
968
|
+
description: option.description ?? null,
|
|
969
|
+
}))
|
|
970
|
+
continue
|
|
971
|
+
}
|
|
972
|
+
if (field === "evidence") {
|
|
973
|
+
canonical.evidence = [...(question.evidence ?? [])]
|
|
974
|
+
continue
|
|
975
|
+
}
|
|
976
|
+
canonical[field] = question[field] ?? null
|
|
977
|
+
}
|
|
978
|
+
return JSON.stringify(canonical)
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// ---------------------------------------------------------------------------
|
|
982
|
+
// Rule evaluation
|
|
983
|
+
// ---------------------------------------------------------------------------
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* Evaluate visibility, implications, conflicts, and group locks.
|
|
987
|
+
*
|
|
988
|
+
* Visibility reads only the **explicit** answers, so an implied answer can
|
|
989
|
+
* never reveal or hide another question. Implications are computed in one
|
|
990
|
+
* pass from explicit answers on visible questions; an implied answer never
|
|
991
|
+
* triggers further implications.
|
|
992
|
+
*
|
|
993
|
+
* @param {object} plan - A normalized published plan.
|
|
994
|
+
* @param {object} state - The plan state (`{ questions, submittedGroups }`).
|
|
995
|
+
* @returns {{ questions: Map<string, object>, lockedGroups: Set<string> }}
|
|
996
|
+
*/
|
|
997
|
+
export function evaluatePlan(plan, state) {
|
|
998
|
+
const questions = plan.questions ?? []
|
|
999
|
+
const stateQuestions = state?.questions ?? {}
|
|
1000
|
+
const submittedGroups = new Set(state?.submittedGroups ?? [])
|
|
1001
|
+
const questionByKey = new Map(questions.map((question) => [question.key, question]))
|
|
1002
|
+
|
|
1003
|
+
const statusOf = (key) => stateQuestions[key]?.status ?? "open"
|
|
1004
|
+
const explicitAnswerOf = (key) => stateQuestions[key]?.answer ?? null
|
|
1005
|
+
|
|
1006
|
+
const visibility = new Map()
|
|
1007
|
+
for (const question of questions) {
|
|
1008
|
+
if (statusOf(question.key) === "retired") {
|
|
1009
|
+
visibility.set(question.key, false)
|
|
1010
|
+
continue
|
|
1011
|
+
}
|
|
1012
|
+
const conditions = question.showIf ?? []
|
|
1013
|
+
if (conditions.length === 0) {
|
|
1014
|
+
visibility.set(question.key, true)
|
|
1015
|
+
continue
|
|
1016
|
+
}
|
|
1017
|
+
const satisfied = conditions.every((condition) => {
|
|
1018
|
+
const target = questionByKey.get(condition.question)
|
|
1019
|
+
if (!target) return false
|
|
1020
|
+
if (statusOf(condition.question) === "retired") return false
|
|
1021
|
+
const answer = explicitAnswerOf(condition.question)
|
|
1022
|
+
if (!answer) return false
|
|
1023
|
+
return selectedOptionIds(target, answer).some((id) => condition.anyOf.includes(id))
|
|
1024
|
+
})
|
|
1025
|
+
visibility.set(question.key, satisfied)
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
const implied = new Map()
|
|
1029
|
+
for (const question of questions) {
|
|
1030
|
+
if (visibility.get(question.key) !== true) continue
|
|
1031
|
+
if (!question.options) continue
|
|
1032
|
+
const answer = explicitAnswerOf(question.key)
|
|
1033
|
+
if (!answer) continue
|
|
1034
|
+
const selected = selectedOptionIds(question, answer)
|
|
1035
|
+
for (const option of question.options) {
|
|
1036
|
+
if (!selected.includes(option.id)) continue
|
|
1037
|
+
for (const implication of option.implies ?? []) {
|
|
1038
|
+
if (visibility.get(implication.question) !== true) continue
|
|
1039
|
+
const target = questionByKey.get(implication.question)
|
|
1040
|
+
if (!target) continue
|
|
1041
|
+
const entries = implied.get(implication.question) ?? []
|
|
1042
|
+
entries.push({
|
|
1043
|
+
from: question.key,
|
|
1044
|
+
reason: implication.reason,
|
|
1045
|
+
answer: toAnswer(target, implication.answer),
|
|
1046
|
+
})
|
|
1047
|
+
implied.set(implication.question, entries)
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
const evaluated = new Map()
|
|
1053
|
+
for (const question of questions) {
|
|
1054
|
+
const visible = visibility.get(question.key) === true
|
|
1055
|
+
const explicit = explicitAnswerOf(question.key)
|
|
1056
|
+
const entries = visible ? implied.get(question.key) ?? [] : []
|
|
1057
|
+
const distinct = new Map()
|
|
1058
|
+
for (const entry of entries) distinct.set(canonicalAnswer(entry.answer), entry.answer)
|
|
1059
|
+
const conflict =
|
|
1060
|
+
distinct.size > 1
|
|
1061
|
+
? entries.map((entry) => ({ from: entry.from, answer: entry.answer }))
|
|
1062
|
+
: []
|
|
1063
|
+
const impliedAnswer = distinct.size === 1 ? [...distinct.values()][0] : null
|
|
1064
|
+
|
|
1065
|
+
let effective = null
|
|
1066
|
+
let source = null
|
|
1067
|
+
let overridesImplied = false
|
|
1068
|
+
if (visible) {
|
|
1069
|
+
if (explicit !== null) {
|
|
1070
|
+
effective = explicit
|
|
1071
|
+
source = "user"
|
|
1072
|
+
overridesImplied = impliedAnswer !== null && !sameAnswer(explicit, impliedAnswer)
|
|
1073
|
+
} else if (impliedAnswer !== null) {
|
|
1074
|
+
effective = impliedAnswer
|
|
1075
|
+
source = "implied"
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
evaluated.set(question.key, {
|
|
1080
|
+
visible,
|
|
1081
|
+
effective,
|
|
1082
|
+
source,
|
|
1083
|
+
impliedBy: entries.map((entry) => entry.from),
|
|
1084
|
+
impliedReasons: entries.map((entry) => entry.reason),
|
|
1085
|
+
overridesImplied,
|
|
1086
|
+
conflict,
|
|
1087
|
+
})
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
const lockedGroups = new Set()
|
|
1091
|
+
for (const group of plan.groups ?? []) {
|
|
1092
|
+
if ((group.dependsOn ?? []).some((dependency) => !submittedGroups.has(dependency))) {
|
|
1093
|
+
lockedGroups.add(group.key)
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
return { questions: evaluated, lockedGroups }
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// ---------------------------------------------------------------------------
|
|
1101
|
+
// Readiness and counts
|
|
1102
|
+
// ---------------------------------------------------------------------------
|
|
1103
|
+
|
|
1104
|
+
/**
|
|
1105
|
+
* Whether a group can be submitted, and what is missing when it cannot.
|
|
1106
|
+
*
|
|
1107
|
+
* A group is ready when it is unlocked, has at least one pending visible
|
|
1108
|
+
* `open` question, every pending question is answered, validly deferred, or
|
|
1109
|
+
* marked "needs discussion", and at least one of them is answered or deferred
|
|
1110
|
+
* so the submission always carries something for the agent.
|
|
1111
|
+
*
|
|
1112
|
+
* @param {object} plan - A normalized published plan.
|
|
1113
|
+
* @param {object} state - The plan state.
|
|
1114
|
+
* @param {object} evaluation - The result of {@link evaluatePlan}.
|
|
1115
|
+
* @param {string} groupKey - The group to inspect.
|
|
1116
|
+
* @returns {{ ready: boolean, locked: boolean, missing: Array<{key: string, reason: string}> }}
|
|
1117
|
+
*/
|
|
1118
|
+
export function groupReadiness(plan, state, evaluation, groupKey) {
|
|
1119
|
+
const stateQuestions = state?.questions ?? {}
|
|
1120
|
+
const locked = evaluation.lockedGroups.has(groupKey)
|
|
1121
|
+
const missing = []
|
|
1122
|
+
const pending = (plan.questions ?? []).filter(
|
|
1123
|
+
(question) =>
|
|
1124
|
+
question.group === groupKey &&
|
|
1125
|
+
evaluation.questions.get(question.key)?.visible === true &&
|
|
1126
|
+
(stateQuestions[question.key]?.status ?? "open") === "open",
|
|
1127
|
+
)
|
|
1128
|
+
let carriesSubmission = false
|
|
1129
|
+
|
|
1130
|
+
for (const question of pending) {
|
|
1131
|
+
const questionState = stateQuestions[question.key] ?? {}
|
|
1132
|
+
const entry = evaluation.questions.get(question.key)
|
|
1133
|
+
if (questionState.changed === true) {
|
|
1134
|
+
missing.push({ key: question.key, reason: "changed" })
|
|
1135
|
+
continue
|
|
1136
|
+
}
|
|
1137
|
+
if (entry.conflict.length > 0 && questionState.answer == null) {
|
|
1138
|
+
missing.push({ key: question.key, reason: "conflict" })
|
|
1139
|
+
continue
|
|
1140
|
+
}
|
|
1141
|
+
if (entry.effective !== null) {
|
|
1142
|
+
carriesSubmission = true
|
|
1143
|
+
continue
|
|
1144
|
+
}
|
|
1145
|
+
if (questionState.defer && validateDefer(questionState.defer).ok) {
|
|
1146
|
+
carriesSubmission = true
|
|
1147
|
+
continue
|
|
1148
|
+
}
|
|
1149
|
+
if (questionState.needsDiscussion === true) continue
|
|
1150
|
+
if (question.proposal !== undefined && questionState.proposalDismissed !== true) {
|
|
1151
|
+
missing.push({ key: question.key, reason: "proposal" })
|
|
1152
|
+
continue
|
|
1153
|
+
}
|
|
1154
|
+
missing.push({ key: question.key, reason: "unanswered" })
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
if (missing.length === 0 && (!carriesSubmission || pending.length === 0)) {
|
|
1158
|
+
missing.push({ key: groupKey, reason: "nothing-to-submit" })
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
return {
|
|
1162
|
+
ready: !locked && pending.length > 0 && missing.length === 0,
|
|
1163
|
+
locked,
|
|
1164
|
+
missing,
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
/**
|
|
1169
|
+
* Counts used by list views. A question lands in exactly one bucket.
|
|
1170
|
+
*
|
|
1171
|
+
* Hidden and retired questions are excluded from every count. `open` counts
|
|
1172
|
+
* visible questions still waiting for an answer, a deferral, or a discussion
|
|
1173
|
+
* marker.
|
|
1174
|
+
*
|
|
1175
|
+
* @param {object} plan - A normalized published plan.
|
|
1176
|
+
* @param {object} state - The plan state.
|
|
1177
|
+
* @param {object} evaluation - The result of {@link evaluatePlan}.
|
|
1178
|
+
* @returns {{ open: number, answered: number, deferred: number, discussion: number,
|
|
1179
|
+
* submitted: number, recorded: number }}
|
|
1180
|
+
*/
|
|
1181
|
+
export function planCounts(plan, state, evaluation) {
|
|
1182
|
+
const counts = {
|
|
1183
|
+
open: 0,
|
|
1184
|
+
answered: 0,
|
|
1185
|
+
deferred: 0,
|
|
1186
|
+
discussion: 0,
|
|
1187
|
+
submitted: 0,
|
|
1188
|
+
recorded: 0,
|
|
1189
|
+
}
|
|
1190
|
+
const stateQuestions = state?.questions ?? {}
|
|
1191
|
+
|
|
1192
|
+
for (const question of plan.questions ?? []) {
|
|
1193
|
+
if (evaluation.questions.get(question.key)?.visible !== true) continue
|
|
1194
|
+
const questionState = stateQuestions[question.key] ?? {}
|
|
1195
|
+
const status = questionState.status ?? "open"
|
|
1196
|
+
if (status === "submitted") {
|
|
1197
|
+
counts.submitted += 1
|
|
1198
|
+
continue
|
|
1199
|
+
}
|
|
1200
|
+
if (status === "recorded") {
|
|
1201
|
+
counts.recorded += 1
|
|
1202
|
+
continue
|
|
1203
|
+
}
|
|
1204
|
+
if (status !== "open") continue
|
|
1205
|
+
const entry = evaluation.questions.get(question.key)
|
|
1206
|
+
if (entry.effective !== null) counts.answered += 1
|
|
1207
|
+
else if (questionState.needsDiscussion === true) counts.discussion += 1
|
|
1208
|
+
else if (questionState.defer && validateDefer(questionState.defer).ok) counts.deferred += 1
|
|
1209
|
+
else counts.open += 1
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
return counts
|
|
1213
|
+
}
|