@forwardimpact/pathway 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +104 -0
  3. package/app/commands/agent.js +430 -0
  4. package/app/commands/behaviour.js +61 -0
  5. package/app/commands/command-factory.js +211 -0
  6. package/app/commands/discipline.js +58 -0
  7. package/app/commands/driver.js +94 -0
  8. package/app/commands/grade.js +60 -0
  9. package/app/commands/index.js +20 -0
  10. package/app/commands/init.js +67 -0
  11. package/app/commands/interview.js +68 -0
  12. package/app/commands/job.js +157 -0
  13. package/app/commands/progress.js +77 -0
  14. package/app/commands/questions.js +179 -0
  15. package/app/commands/serve.js +143 -0
  16. package/app/commands/site.js +121 -0
  17. package/app/commands/skill.js +76 -0
  18. package/app/commands/stage.js +129 -0
  19. package/app/commands/track.js +70 -0
  20. package/app/components/action-buttons.js +66 -0
  21. package/app/components/behaviour-profile.js +53 -0
  22. package/app/components/builder.js +341 -0
  23. package/app/components/card.js +98 -0
  24. package/app/components/checklist.js +145 -0
  25. package/app/components/comparison-radar.js +237 -0
  26. package/app/components/detail.js +230 -0
  27. package/app/components/error-page.js +72 -0
  28. package/app/components/grid.js +109 -0
  29. package/app/components/list.js +120 -0
  30. package/app/components/modifier-table.js +142 -0
  31. package/app/components/nav.js +64 -0
  32. package/app/components/progression-table.js +320 -0
  33. package/app/components/radar-chart.js +102 -0
  34. package/app/components/skill-matrix.js +97 -0
  35. package/app/css/base.css +56 -0
  36. package/app/css/bundles/app.css +40 -0
  37. package/app/css/bundles/handout.css +43 -0
  38. package/app/css/bundles/slides.css +40 -0
  39. package/app/css/components/badges.css +215 -0
  40. package/app/css/components/buttons.css +101 -0
  41. package/app/css/components/forms.css +105 -0
  42. package/app/css/components/layout.css +209 -0
  43. package/app/css/components/nav.css +166 -0
  44. package/app/css/components/progress.css +166 -0
  45. package/app/css/components/states.css +82 -0
  46. package/app/css/components/surfaces.css +243 -0
  47. package/app/css/components/tables.css +362 -0
  48. package/app/css/components/typography.css +122 -0
  49. package/app/css/components/utilities.css +41 -0
  50. package/app/css/pages/agent-builder.css +391 -0
  51. package/app/css/pages/assessment-results.css +453 -0
  52. package/app/css/pages/detail.css +59 -0
  53. package/app/css/pages/interview-builder.css +148 -0
  54. package/app/css/pages/job-builder.css +134 -0
  55. package/app/css/pages/landing.css +92 -0
  56. package/app/css/pages/lifecycle.css +118 -0
  57. package/app/css/pages/progress-builder.css +274 -0
  58. package/app/css/pages/self-assessment.css +502 -0
  59. package/app/css/reset.css +50 -0
  60. package/app/css/tokens.css +153 -0
  61. package/app/css/views/handout.css +30 -0
  62. package/app/css/views/print.css +608 -0
  63. package/app/css/views/slide-animations.css +113 -0
  64. package/app/css/views/slide-base.css +330 -0
  65. package/app/css/views/slide-sections.css +597 -0
  66. package/app/css/views/slide-tables.css +275 -0
  67. package/app/formatters/agent/dom.js +540 -0
  68. package/app/formatters/agent/profile.js +133 -0
  69. package/app/formatters/agent/skill.js +58 -0
  70. package/app/formatters/behaviour/dom.js +91 -0
  71. package/app/formatters/behaviour/markdown.js +54 -0
  72. package/app/formatters/behaviour/shared.js +64 -0
  73. package/app/formatters/discipline/dom.js +187 -0
  74. package/app/formatters/discipline/markdown.js +87 -0
  75. package/app/formatters/discipline/shared.js +131 -0
  76. package/app/formatters/driver/dom.js +103 -0
  77. package/app/formatters/driver/shared.js +92 -0
  78. package/app/formatters/grade/dom.js +208 -0
  79. package/app/formatters/grade/markdown.js +94 -0
  80. package/app/formatters/grade/shared.js +86 -0
  81. package/app/formatters/index.js +50 -0
  82. package/app/formatters/interview/dom.js +97 -0
  83. package/app/formatters/interview/markdown.js +66 -0
  84. package/app/formatters/interview/shared.js +332 -0
  85. package/app/formatters/job/description.js +176 -0
  86. package/app/formatters/job/dom.js +411 -0
  87. package/app/formatters/job/markdown.js +102 -0
  88. package/app/formatters/progress/dom.js +135 -0
  89. package/app/formatters/progress/markdown.js +86 -0
  90. package/app/formatters/progress/shared.js +339 -0
  91. package/app/formatters/questions/json.js +43 -0
  92. package/app/formatters/questions/markdown.js +303 -0
  93. package/app/formatters/questions/shared.js +274 -0
  94. package/app/formatters/questions/yaml.js +76 -0
  95. package/app/formatters/shared.js +71 -0
  96. package/app/formatters/skill/dom.js +168 -0
  97. package/app/formatters/skill/markdown.js +109 -0
  98. package/app/formatters/skill/shared.js +125 -0
  99. package/app/formatters/stage/dom.js +135 -0
  100. package/app/formatters/stage/index.js +12 -0
  101. package/app/formatters/stage/shared.js +111 -0
  102. package/app/formatters/track/dom.js +128 -0
  103. package/app/formatters/track/markdown.js +105 -0
  104. package/app/formatters/track/shared.js +181 -0
  105. package/app/handout-main.js +421 -0
  106. package/app/handout.html +21 -0
  107. package/app/index.html +59 -0
  108. package/app/lib/card-mappers.js +173 -0
  109. package/app/lib/cli-output.js +270 -0
  110. package/app/lib/error-boundary.js +70 -0
  111. package/app/lib/errors.js +49 -0
  112. package/app/lib/form-controls.js +47 -0
  113. package/app/lib/job-cache.js +86 -0
  114. package/app/lib/markdown.js +114 -0
  115. package/app/lib/radar.js +866 -0
  116. package/app/lib/reactive.js +77 -0
  117. package/app/lib/render.js +212 -0
  118. package/app/lib/router-core.js +160 -0
  119. package/app/lib/router-pages.js +16 -0
  120. package/app/lib/router-slides.js +202 -0
  121. package/app/lib/state.js +148 -0
  122. package/app/lib/utils.js +14 -0
  123. package/app/lib/yaml-loader.js +327 -0
  124. package/app/main.js +213 -0
  125. package/app/model/agent.js +702 -0
  126. package/app/model/checklist.js +137 -0
  127. package/app/model/derivation.js +699 -0
  128. package/app/model/index-generator.js +71 -0
  129. package/app/model/interview.js +539 -0
  130. package/app/model/job.js +222 -0
  131. package/app/model/levels.js +591 -0
  132. package/app/model/loader.js +564 -0
  133. package/app/model/matching.js +858 -0
  134. package/app/model/modifiers.js +158 -0
  135. package/app/model/profile.js +266 -0
  136. package/app/model/progression.js +507 -0
  137. package/app/model/validation.js +1385 -0
  138. package/app/pages/agent-builder.js +823 -0
  139. package/app/pages/assessment-results.js +507 -0
  140. package/app/pages/behaviour.js +70 -0
  141. package/app/pages/discipline.js +71 -0
  142. package/app/pages/driver.js +106 -0
  143. package/app/pages/grade.js +117 -0
  144. package/app/pages/interview-builder.js +50 -0
  145. package/app/pages/interview.js +304 -0
  146. package/app/pages/job-builder.js +50 -0
  147. package/app/pages/job.js +58 -0
  148. package/app/pages/landing.js +305 -0
  149. package/app/pages/progress-builder.js +58 -0
  150. package/app/pages/progress.js +495 -0
  151. package/app/pages/self-assessment.js +729 -0
  152. package/app/pages/skill.js +113 -0
  153. package/app/pages/stage.js +231 -0
  154. package/app/pages/track.js +69 -0
  155. package/app/slide-main.js +360 -0
  156. package/app/slides/behaviour.js +38 -0
  157. package/app/slides/chapter.js +82 -0
  158. package/app/slides/discipline.js +40 -0
  159. package/app/slides/driver.js +39 -0
  160. package/app/slides/grade.js +32 -0
  161. package/app/slides/index.js +198 -0
  162. package/app/slides/interview.js +58 -0
  163. package/app/slides/job.js +55 -0
  164. package/app/slides/overview.js +126 -0
  165. package/app/slides/progress.js +83 -0
  166. package/app/slides/skill.js +40 -0
  167. package/app/slides/track.js +39 -0
  168. package/app/slides.html +56 -0
  169. package/app/types.js +147 -0
  170. package/bin/pathway.js +489 -0
  171. package/examples/agents/.claude/skills/architecture-design/SKILL.md +88 -0
  172. package/examples/agents/.claude/skills/cloud-platforms/SKILL.md +90 -0
  173. package/examples/agents/.claude/skills/code-quality-review/SKILL.md +67 -0
  174. package/examples/agents/.claude/skills/data-modeling/SKILL.md +99 -0
  175. package/examples/agents/.claude/skills/developer-experience/SKILL.md +99 -0
  176. package/examples/agents/.claude/skills/devops-cicd/SKILL.md +96 -0
  177. package/examples/agents/.claude/skills/full-stack-development/SKILL.md +90 -0
  178. package/examples/agents/.claude/skills/knowledge-management/SKILL.md +100 -0
  179. package/examples/agents/.claude/skills/pattern-generalization/SKILL.md +102 -0
  180. package/examples/agents/.claude/skills/sre-practices/SKILL.md +117 -0
  181. package/examples/agents/.claude/skills/technical-debt-management/SKILL.md +123 -0
  182. package/examples/agents/.claude/skills/technical-writing/SKILL.md +129 -0
  183. package/examples/agents/.github/agents/se-platform-code.agent.md +181 -0
  184. package/examples/agents/.github/agents/se-platform-plan.agent.md +178 -0
  185. package/examples/agents/.github/agents/se-platform-review.agent.md +113 -0
  186. package/examples/agents/.vscode/settings.json +8 -0
  187. package/examples/behaviours/_index.yaml +8 -0
  188. package/examples/behaviours/outcome_ownership.yaml +44 -0
  189. package/examples/behaviours/polymathic_knowledge.yaml +42 -0
  190. package/examples/behaviours/precise_communication.yaml +40 -0
  191. package/examples/behaviours/relentless_curiosity.yaml +38 -0
  192. package/examples/behaviours/systems_thinking.yaml +41 -0
  193. package/examples/capabilities/_index.yaml +8 -0
  194. package/examples/capabilities/business.yaml +251 -0
  195. package/examples/capabilities/delivery.yaml +352 -0
  196. package/examples/capabilities/people.yaml +100 -0
  197. package/examples/capabilities/reliability.yaml +318 -0
  198. package/examples/capabilities/scale.yaml +394 -0
  199. package/examples/disciplines/_index.yaml +5 -0
  200. package/examples/disciplines/data_engineering.yaml +76 -0
  201. package/examples/disciplines/software_engineering.yaml +76 -0
  202. package/examples/drivers.yaml +205 -0
  203. package/examples/framework.yaml +58 -0
  204. package/examples/grades.yaml +118 -0
  205. package/examples/questions/behaviours/outcome_ownership.yaml +52 -0
  206. package/examples/questions/behaviours/polymathic_knowledge.yaml +48 -0
  207. package/examples/questions/behaviours/precise_communication.yaml +55 -0
  208. package/examples/questions/behaviours/relentless_curiosity.yaml +51 -0
  209. package/examples/questions/behaviours/systems_thinking.yaml +53 -0
  210. package/examples/questions/skills/architecture_design.yaml +54 -0
  211. package/examples/questions/skills/cloud_platforms.yaml +48 -0
  212. package/examples/questions/skills/code_quality.yaml +49 -0
  213. package/examples/questions/skills/data_modeling.yaml +46 -0
  214. package/examples/questions/skills/devops.yaml +47 -0
  215. package/examples/questions/skills/full_stack_development.yaml +48 -0
  216. package/examples/questions/skills/sre_practices.yaml +44 -0
  217. package/examples/questions/skills/stakeholder_management.yaml +49 -0
  218. package/examples/questions/skills/team_collaboration.yaml +43 -0
  219. package/examples/questions/skills/technical_writing.yaml +43 -0
  220. package/examples/self-assessments.yaml +66 -0
  221. package/examples/stages.yaml +76 -0
  222. package/examples/tracks/_index.yaml +6 -0
  223. package/examples/tracks/manager.yaml +53 -0
  224. package/examples/tracks/platform.yaml +54 -0
  225. package/examples/tracks/sre.yaml +58 -0
  226. package/examples/vscode-settings.yaml +22 -0
  227. package/package.json +68 -0
@@ -0,0 +1,1385 @@
1
+ /**
2
+ * Engineering Pathway Validation Functions
3
+ *
4
+ * This module provides comprehensive data validation with referential integrity checks.
5
+ */
6
+
7
+ import {
8
+ Capability,
9
+ Stage,
10
+ getSkillLevelIndex,
11
+ getBehaviourMaturityIndex,
12
+ } from "./levels.js";
13
+
14
+ import { isCapability } from "./modifiers.js";
15
+
16
+ /**
17
+ * Create a validation result object
18
+ * @param {boolean} valid - Whether validation passed
19
+ * @param {Array} errors - Array of errors
20
+ * @param {Array} warnings - Array of warnings
21
+ * @returns {import('./levels.js').ValidationResult}
22
+ */
23
+ function createValidationResult(valid, errors = [], warnings = []) {
24
+ return { valid, errors, warnings };
25
+ }
26
+
27
+ /**
28
+ * Create a validation error
29
+ * @param {string} type - Error type
30
+ * @param {string} message - Error message
31
+ * @param {string} [path] - Path to invalid data
32
+ * @param {*} [value] - Invalid value
33
+ * @returns {import('./levels.js').ValidationError}
34
+ */
35
+ function createError(type, message, path, value) {
36
+ const error = { type, message };
37
+ if (path !== undefined) error.path = path;
38
+ if (value !== undefined) error.value = value;
39
+ return error;
40
+ }
41
+
42
+ /**
43
+ * Create a validation warning
44
+ * @param {string} type - Warning type
45
+ * @param {string} message - Warning message
46
+ * @param {string} [path] - Path to concerning data
47
+ * @returns {import('./levels.js').ValidationWarning}
48
+ */
49
+ function createWarning(type, message, path) {
50
+ const warning = { type, message };
51
+ if (path !== undefined) warning.path = path;
52
+ return warning;
53
+ }
54
+
55
+ /**
56
+ * Validate that a skill has required properties
57
+ * @param {import('./levels.js').Skill} skill - Skill to validate
58
+ * @param {number} index - Index in the skills array
59
+ * @returns {{errors: Array, warnings: Array}}
60
+ */
61
+ function validateSkill(skill, index) {
62
+ const errors = [];
63
+ const warnings = [];
64
+ const path = `skills[${index}]`;
65
+
66
+ if (!skill.id) {
67
+ errors.push(createError("MISSING_REQUIRED", "Skill missing id", path));
68
+ }
69
+ if (!skill.name) {
70
+ errors.push(
71
+ createError("MISSING_REQUIRED", "Skill missing name", `${path}.name`),
72
+ );
73
+ }
74
+ if (!skill.capability) {
75
+ errors.push(
76
+ createError(
77
+ "MISSING_REQUIRED",
78
+ "Skill missing capability",
79
+ `${path}.capability`,
80
+ ),
81
+ );
82
+ } else if (!Object.values(Capability).includes(skill.capability)) {
83
+ errors.push(
84
+ createError(
85
+ "INVALID_VALUE",
86
+ `Invalid skill capability: ${skill.capability}`,
87
+ `${path}.capability`,
88
+ skill.capability,
89
+ ),
90
+ );
91
+ }
92
+ if (!skill.description) {
93
+ warnings.push(
94
+ createWarning(
95
+ "MISSING_OPTIONAL",
96
+ "Skill missing description",
97
+ `${path}.description`,
98
+ ),
99
+ );
100
+ }
101
+ if (!skill.levelDescriptions) {
102
+ warnings.push(
103
+ createWarning(
104
+ "MISSING_OPTIONAL",
105
+ "Skill missing level descriptions",
106
+ `${path}.levelDescriptions`,
107
+ ),
108
+ );
109
+ }
110
+
111
+ return { errors, warnings };
112
+ }
113
+
114
+ /**
115
+ * Validate that a behaviour has required properties
116
+ * @param {import('./levels.js').Behaviour} behaviour - Behaviour to validate
117
+ * @param {number} index - Index in the behaviours array
118
+ * @returns {{errors: Array, warnings: Array}}
119
+ */
120
+ function validateBehaviour(behaviour, index) {
121
+ const errors = [];
122
+ const warnings = [];
123
+ const path = `behaviours[${index}]`;
124
+
125
+ // id is derived from filename by the loader
126
+ if (!behaviour.name) {
127
+ errors.push(
128
+ createError("MISSING_REQUIRED", "Behaviour missing name", `${path}.name`),
129
+ );
130
+ }
131
+ if (!behaviour.description) {
132
+ warnings.push(
133
+ createWarning(
134
+ "MISSING_OPTIONAL",
135
+ "Behaviour missing description",
136
+ `${path}.description`,
137
+ ),
138
+ );
139
+ }
140
+ if (!behaviour.maturityDescriptions) {
141
+ warnings.push(
142
+ createWarning(
143
+ "MISSING_OPTIONAL",
144
+ "Behaviour missing maturity descriptions",
145
+ `${path}.maturityDescriptions`,
146
+ ),
147
+ );
148
+ }
149
+
150
+ return { errors, warnings };
151
+ }
152
+
153
+ /**
154
+ * Validate that a driver has required properties and valid references
155
+ * @param {import('./levels.js').Driver} driver - Driver to validate
156
+ * @param {number} index - Index in the drivers array
157
+ * @param {Set<string>} skillIds - Set of valid skill IDs
158
+ * @param {Set<string>} behaviourIds - Set of valid behaviour IDs
159
+ * @returns {{errors: Array, warnings: Array}}
160
+ */
161
+ function validateDriver(driver, index, skillIds, behaviourIds) {
162
+ const errors = [];
163
+ const warnings = [];
164
+ const path = `drivers[${index}]`;
165
+
166
+ if (!driver.id) {
167
+ errors.push(createError("MISSING_REQUIRED", "Driver missing id", path));
168
+ }
169
+ if (!driver.name) {
170
+ errors.push(
171
+ createError("MISSING_REQUIRED", "Driver missing name", `${path}.name`),
172
+ );
173
+ }
174
+ if (!driver.description) {
175
+ warnings.push(
176
+ createWarning(
177
+ "MISSING_OPTIONAL",
178
+ "Driver missing description",
179
+ `${path}.description`,
180
+ ),
181
+ );
182
+ }
183
+
184
+ // Validate contributing skills
185
+ if (driver.contributingSkills) {
186
+ driver.contributingSkills.forEach((skillId, i) => {
187
+ if (!skillIds.has(skillId)) {
188
+ errors.push(
189
+ createError(
190
+ "INVALID_REFERENCE",
191
+ `Driver "${driver.id}" references non-existent skill: ${skillId}`,
192
+ `${path}.contributingSkills[${i}]`,
193
+ skillId,
194
+ ),
195
+ );
196
+ }
197
+ });
198
+ }
199
+
200
+ // Validate contributing behaviours
201
+ if (driver.contributingBehaviours) {
202
+ driver.contributingBehaviours.forEach((behaviourId, i) => {
203
+ if (!behaviourIds.has(behaviourId)) {
204
+ errors.push(
205
+ createError(
206
+ "INVALID_REFERENCE",
207
+ `Driver "${driver.id}" references non-existent behaviour: ${behaviourId}`,
208
+ `${path}.contributingBehaviours[${i}]`,
209
+ behaviourId,
210
+ ),
211
+ );
212
+ }
213
+ });
214
+ }
215
+
216
+ return { errors, warnings };
217
+ }
218
+
219
+ /**
220
+ * Validate that a discipline has required properties and valid references
221
+ * @param {import('./levels.js').Discipline} discipline - Discipline to validate
222
+ * @param {number} index - Index in the disciplines array
223
+ * @param {Set<string>} skillIds - Set of valid skill IDs
224
+ * @param {Set<string>} behaviourIds - Set of valid behaviour IDs
225
+ * @returns {{errors: Array, warnings: Array}}
226
+ */
227
+ function validateDiscipline(discipline, index, skillIds, behaviourIds) {
228
+ const errors = [];
229
+ const warnings = [];
230
+ const path = `disciplines[${index}]`;
231
+
232
+ // id is derived from filename by the loader
233
+ if (!discipline.specialization) {
234
+ errors.push(
235
+ createError(
236
+ "MISSING_REQUIRED",
237
+ "Discipline missing specialization",
238
+ `${path}.specialization`,
239
+ ),
240
+ );
241
+ }
242
+ if (!discipline.roleTitle) {
243
+ errors.push(
244
+ createError(
245
+ "MISSING_REQUIRED",
246
+ "Discipline missing roleTitle",
247
+ `${path}.roleTitle`,
248
+ ),
249
+ );
250
+ }
251
+
252
+ // Validate core skills
253
+ if (!discipline.coreSkills || discipline.coreSkills.length === 0) {
254
+ errors.push(
255
+ createError(
256
+ "MISSING_REQUIRED",
257
+ "Discipline must have at least one core skill",
258
+ `${path}.coreSkills`,
259
+ ),
260
+ );
261
+ } else {
262
+ discipline.coreSkills.forEach((skillId, i) => {
263
+ if (!skillIds.has(skillId)) {
264
+ errors.push(
265
+ createError(
266
+ "INVALID_REFERENCE",
267
+ `Discipline "${discipline.id}" references non-existent core skill: ${skillId}`,
268
+ `${path}.coreSkills[${i}]`,
269
+ skillId,
270
+ ),
271
+ );
272
+ }
273
+ });
274
+ }
275
+
276
+ // Validate supporting skills
277
+ if (discipline.supportingSkills) {
278
+ discipline.supportingSkills.forEach((skillId, i) => {
279
+ if (!skillIds.has(skillId)) {
280
+ errors.push(
281
+ createError(
282
+ "INVALID_REFERENCE",
283
+ `Discipline "${discipline.id}" references non-existent supporting skill: ${skillId}`,
284
+ `${path}.supportingSkills[${i}]`,
285
+ skillId,
286
+ ),
287
+ );
288
+ }
289
+ });
290
+ }
291
+
292
+ // Validate broad skills
293
+ if (discipline.broadSkills) {
294
+ discipline.broadSkills.forEach((skillId, i) => {
295
+ if (!skillIds.has(skillId)) {
296
+ errors.push(
297
+ createError(
298
+ "INVALID_REFERENCE",
299
+ `Discipline "${discipline.id}" references non-existent broad skill: ${skillId}`,
300
+ `${path}.broadSkills[${i}]`,
301
+ skillId,
302
+ ),
303
+ );
304
+ }
305
+ });
306
+ }
307
+
308
+ // Validate behaviour modifiers
309
+ if (discipline.behaviourModifiers) {
310
+ Object.entries(discipline.behaviourModifiers).forEach(
311
+ ([behaviourId, modifier]) => {
312
+ if (!behaviourIds.has(behaviourId)) {
313
+ errors.push(
314
+ createError(
315
+ "INVALID_REFERENCE",
316
+ `Discipline "${discipline.id}" references non-existent behaviour: ${behaviourId}`,
317
+ `${path}.behaviourModifiers.${behaviourId}`,
318
+ behaviourId,
319
+ ),
320
+ );
321
+ }
322
+ if (typeof modifier !== "number" || modifier < -1 || modifier > 1) {
323
+ errors.push(
324
+ createError(
325
+ "INVALID_VALUE",
326
+ `Discipline "${discipline.id}" has invalid behaviour modifier: ${modifier} (must be -1, 0, or 1)`,
327
+ `${path}.behaviourModifiers.${behaviourId}`,
328
+ modifier,
329
+ ),
330
+ );
331
+ }
332
+ },
333
+ );
334
+ }
335
+
336
+ return { errors, warnings };
337
+ }
338
+
339
+ /**
340
+ * Get all skill IDs referenced by any discipline
341
+ * @param {import('./levels.js').Discipline[]} disciplines - Array of disciplines
342
+ * @returns {Set<string>} Set of all referenced skill IDs
343
+ */
344
+ function getAllDisciplineSkillIds(disciplines) {
345
+ const skillIds = new Set();
346
+ for (const discipline of disciplines) {
347
+ (discipline.coreSkills || []).forEach((id) => skillIds.add(id));
348
+ (discipline.supportingSkills || []).forEach((id) => skillIds.add(id));
349
+ (discipline.broadSkills || []).forEach((id) => skillIds.add(id));
350
+ }
351
+ return skillIds;
352
+ }
353
+
354
+ /**
355
+ * Validate that a track has required properties and valid references
356
+ * @param {import('./levels.js').Track} track - Track to validate
357
+ * @param {number} index - Index in the tracks array
358
+ * @param {Set<string>} disciplineSkillIds - Set of skill IDs used in any discipline
359
+ * @param {Set<string>} behaviourIds - Set of valid behaviour IDs
360
+ * @param {Set<string>} disciplineIds - Set of valid discipline IDs
361
+ * @returns {{errors: Array, warnings: Array}}
362
+ */
363
+ function validateTrack(
364
+ track,
365
+ index,
366
+ disciplineSkillIds,
367
+ behaviourIds,
368
+ disciplineIds,
369
+ gradeIds,
370
+ ) {
371
+ const errors = [];
372
+ const warnings = [];
373
+ const path = `tracks[${index}]`;
374
+
375
+ // id is derived from filename by the loader
376
+ if (!track.name) {
377
+ errors.push(
378
+ createError("MISSING_REQUIRED", "Track missing name", `${path}.name`),
379
+ );
380
+ }
381
+
382
+ // Validate isProfessional/isManagement booleans (optional, default to isProfessional: true)
383
+ if (
384
+ track.isProfessional !== undefined &&
385
+ typeof track.isProfessional !== "boolean"
386
+ ) {
387
+ errors.push(
388
+ createError(
389
+ "INVALID_VALUE",
390
+ `Track "${track.id}" has invalid isProfessional value: ${track.isProfessional} (must be boolean)`,
391
+ `${path}.isProfessional`,
392
+ track.isProfessional,
393
+ ),
394
+ );
395
+ }
396
+ if (
397
+ track.isManagement !== undefined &&
398
+ typeof track.isManagement !== "boolean"
399
+ ) {
400
+ errors.push(
401
+ createError(
402
+ "INVALID_VALUE",
403
+ `Track "${track.id}" has invalid isManagement value: ${track.isManagement} (must be boolean)`,
404
+ `${path}.isManagement`,
405
+ track.isManagement,
406
+ ),
407
+ );
408
+ }
409
+
410
+ // Validate skill modifiers - must be capabilities only (not individual skill IDs)
411
+ if (track.skillModifiers) {
412
+ Object.entries(track.skillModifiers).forEach(([key, modifier]) => {
413
+ // Key must be a capability - individual skill IDs are not allowed
414
+ if (!isCapability(key)) {
415
+ errors.push(
416
+ createError(
417
+ "INVALID_SKILL_MODIFIER_KEY",
418
+ `Track "${track.id}" has invalid skillModifier key "${key}". Only capability names are allowed: delivery, data, ai, scale, reliability, people, process, business, documentation`,
419
+ `${path}.skillModifiers.${key}`,
420
+ key,
421
+ ),
422
+ );
423
+ }
424
+ if (typeof modifier !== "number" || !Number.isInteger(modifier)) {
425
+ errors.push(
426
+ createError(
427
+ "INVALID_VALUE",
428
+ `Track "${track.id}" has invalid skill modifier: ${modifier} (must be an integer)`,
429
+ `${path}.skillModifiers.${key}`,
430
+ modifier,
431
+ ),
432
+ );
433
+ }
434
+ });
435
+ }
436
+
437
+ // Validate behaviour modifiers
438
+ if (track.behaviourModifiers) {
439
+ Object.entries(track.behaviourModifiers).forEach(
440
+ ([behaviourId, modifier]) => {
441
+ if (!behaviourIds.has(behaviourId)) {
442
+ errors.push(
443
+ createError(
444
+ "INVALID_REFERENCE",
445
+ `Track "${track.id}" references non-existent behaviour: ${behaviourId}`,
446
+ `${path}.behaviourModifiers.${behaviourId}`,
447
+ behaviourId,
448
+ ),
449
+ );
450
+ }
451
+ if (typeof modifier !== "number" || !Number.isInteger(modifier)) {
452
+ errors.push(
453
+ createError(
454
+ "INVALID_VALUE",
455
+ `Track "${track.id}" has invalid behaviour modifier: ${modifier} (must be an integer)`,
456
+ `${path}.behaviourModifiers.${behaviourId}`,
457
+ modifier,
458
+ ),
459
+ );
460
+ }
461
+ },
462
+ );
463
+ }
464
+
465
+ // Validate validDisciplines if specified
466
+ if (track.validDisciplines) {
467
+ track.validDisciplines.forEach((disciplineId, i) => {
468
+ if (!disciplineIds.has(disciplineId)) {
469
+ errors.push(
470
+ createError(
471
+ "INVALID_REFERENCE",
472
+ `Track "${track.id}" references non-existent discipline: ${disciplineId}`,
473
+ `${path}.validDisciplines[${i}]`,
474
+ disciplineId,
475
+ ),
476
+ );
477
+ }
478
+ });
479
+ }
480
+
481
+ // Validate minGrade if specified
482
+ if (track.minGrade) {
483
+ if (!gradeIds.has(track.minGrade)) {
484
+ errors.push(
485
+ createError(
486
+ "INVALID_REFERENCE",
487
+ `Track "${track.id}" references non-existent grade: ${track.minGrade}`,
488
+ `${path}.minGrade`,
489
+ track.minGrade,
490
+ ),
491
+ );
492
+ }
493
+ }
494
+
495
+ // Validate assessment weights if specified
496
+ if (track.assessmentWeights) {
497
+ const { skillWeight, behaviourWeight } = track.assessmentWeights;
498
+ if (typeof skillWeight !== "number" || skillWeight < 0 || skillWeight > 1) {
499
+ errors.push(
500
+ createError(
501
+ "INVALID_VALUE",
502
+ `Track "${track.id}" has invalid assessmentWeights.skillWeight: ${skillWeight}`,
503
+ `${path}.assessmentWeights.skillWeight`,
504
+ skillWeight,
505
+ ),
506
+ );
507
+ }
508
+ if (
509
+ typeof behaviourWeight !== "number" ||
510
+ behaviourWeight < 0 ||
511
+ behaviourWeight > 1
512
+ ) {
513
+ errors.push(
514
+ createError(
515
+ "INVALID_VALUE",
516
+ `Track "${track.id}" has invalid assessmentWeights.behaviourWeight: ${behaviourWeight}`,
517
+ `${path}.assessmentWeights.behaviourWeight`,
518
+ behaviourWeight,
519
+ ),
520
+ );
521
+ }
522
+ if (
523
+ typeof skillWeight === "number" &&
524
+ typeof behaviourWeight === "number"
525
+ ) {
526
+ const sum = skillWeight + behaviourWeight;
527
+ if (Math.abs(sum - 1.0) > 0.001) {
528
+ errors.push(
529
+ createError(
530
+ "INVALID_VALUE",
531
+ `Track "${track.id}" assessmentWeights must sum to 1.0 (got ${sum})`,
532
+ `${path}.assessmentWeights`,
533
+ { skillWeight, behaviourWeight },
534
+ ),
535
+ );
536
+ }
537
+ }
538
+ }
539
+
540
+ return { errors, warnings };
541
+ }
542
+
543
+ /**
544
+ * Validate that a grade has required properties and valid values
545
+ * @param {import('./levels.js').Grade} grade - Grade to validate
546
+ * @param {number} index - Index in the grades array
547
+ * @returns {{errors: Array, warnings: Array}}
548
+ */
549
+ function validateGrade(grade, index) {
550
+ const errors = [];
551
+ const warnings = [];
552
+ const path = `grades[${index}]`;
553
+
554
+ if (!grade.id) {
555
+ errors.push(createError("MISSING_REQUIRED", "Grade missing id", path));
556
+ }
557
+
558
+ if (!grade.professionalTitle) {
559
+ errors.push(
560
+ createError(
561
+ "MISSING_REQUIRED",
562
+ "Grade missing professionalTitle",
563
+ `${path}.professionalTitle`,
564
+ ),
565
+ );
566
+ }
567
+ if (!grade.managementTitle) {
568
+ errors.push(
569
+ createError(
570
+ "MISSING_REQUIRED",
571
+ "Grade missing managementTitle",
572
+ `${path}.managementTitle`,
573
+ ),
574
+ );
575
+ }
576
+
577
+ if (typeof grade.ordinalRank !== "number") {
578
+ errors.push(
579
+ createError(
580
+ "MISSING_REQUIRED",
581
+ "Grade missing numeric ordinalRank",
582
+ `${path}.ordinalRank`,
583
+ ),
584
+ );
585
+ }
586
+
587
+ // Validate base skill levels
588
+ if (!grade.baseSkillLevels) {
589
+ errors.push(
590
+ createError(
591
+ "MISSING_REQUIRED",
592
+ "Grade missing baseSkillLevels",
593
+ `${path}.baseSkillLevels`,
594
+ ),
595
+ );
596
+ } else {
597
+ ["primary", "secondary", "broad"].forEach((type) => {
598
+ const level = grade.baseSkillLevels[type];
599
+ if (!level) {
600
+ errors.push(
601
+ createError(
602
+ "MISSING_REQUIRED",
603
+ `Grade missing baseSkillLevels.${type}`,
604
+ `${path}.baseSkillLevels.${type}`,
605
+ ),
606
+ );
607
+ } else if (getSkillLevelIndex(level) === -1) {
608
+ errors.push(
609
+ createError(
610
+ "INVALID_VALUE",
611
+ `Grade "${grade.id}" has invalid baseSkillLevels.${type}: ${level}`,
612
+ `${path}.baseSkillLevels.${type}`,
613
+ level,
614
+ ),
615
+ );
616
+ }
617
+ });
618
+ }
619
+
620
+ // Validate base behaviour maturity
621
+ if (!grade.baseBehaviourMaturity) {
622
+ errors.push(
623
+ createError(
624
+ "MISSING_REQUIRED",
625
+ "Grade missing baseBehaviourMaturity",
626
+ `${path}.baseBehaviourMaturity`,
627
+ ),
628
+ );
629
+ } else if (getBehaviourMaturityIndex(grade.baseBehaviourMaturity) === -1) {
630
+ errors.push(
631
+ createError(
632
+ "INVALID_VALUE",
633
+ `Grade "${grade.id}" has invalid baseBehaviourMaturity: ${grade.baseBehaviourMaturity}`,
634
+ `${path}.baseBehaviourMaturity`,
635
+ grade.baseBehaviourMaturity,
636
+ ),
637
+ );
638
+ }
639
+
640
+ // Validate expectations
641
+ if (!grade.expectations) {
642
+ warnings.push(
643
+ createWarning(
644
+ "MISSING_OPTIONAL",
645
+ "Grade missing expectations",
646
+ `${path}.expectations`,
647
+ ),
648
+ );
649
+ }
650
+
651
+ // Validate yearsExperience if present (should be a string like "0-2" or "20+")
652
+ if (
653
+ grade.yearsExperience !== undefined &&
654
+ typeof grade.yearsExperience !== "string"
655
+ ) {
656
+ warnings.push(
657
+ createWarning(
658
+ "INVALID_VALUE",
659
+ "Grade yearsExperience should be a string",
660
+ `${path}.yearsExperience`,
661
+ ),
662
+ );
663
+ }
664
+
665
+ return { errors, warnings };
666
+ }
667
+
668
+ /**
669
+ * Validate that a capability has required properties
670
+ * @param {Object} capability - Capability to validate
671
+ * @param {number} index - Index in the capabilities array
672
+ * @returns {{errors: Array, warnings: Array}}
673
+ */
674
+ function validateCapability(capability, index) {
675
+ const errors = [];
676
+ const warnings = [];
677
+ const path = `capabilities[${index}]`;
678
+
679
+ // id is derived from filename by the loader
680
+ if (!capability.name) {
681
+ errors.push(
682
+ createError(
683
+ "MISSING_REQUIRED",
684
+ "Capability missing name",
685
+ `${path}.name`,
686
+ ),
687
+ );
688
+ }
689
+ if (!capability.emoji) {
690
+ warnings.push(
691
+ createWarning(
692
+ "MISSING_OPTIONAL",
693
+ "Capability missing emoji",
694
+ `${path}.emoji`,
695
+ ),
696
+ );
697
+ }
698
+
699
+ // Validate professionalResponsibilities and managementResponsibilities
700
+ const expectedLevels = [
701
+ "awareness",
702
+ "foundational",
703
+ "working",
704
+ "practitioner",
705
+ "expert",
706
+ ];
707
+
708
+ if (!capability.professionalResponsibilities) {
709
+ warnings.push(
710
+ createWarning(
711
+ "MISSING_OPTIONAL",
712
+ "Capability missing professionalResponsibilities",
713
+ `${path}.professionalResponsibilities`,
714
+ ),
715
+ );
716
+ } else {
717
+ for (const level of expectedLevels) {
718
+ if (!capability.professionalResponsibilities[level]) {
719
+ warnings.push(
720
+ createWarning(
721
+ "MISSING_OPTIONAL",
722
+ `Capability missing ${level} professional responsibility`,
723
+ `${path}.professionalResponsibilities.${level}`,
724
+ ),
725
+ );
726
+ }
727
+ }
728
+ }
729
+
730
+ if (!capability.managementResponsibilities) {
731
+ warnings.push(
732
+ createWarning(
733
+ "MISSING_OPTIONAL",
734
+ "Capability missing managementResponsibilities",
735
+ `${path}.managementResponsibilities`,
736
+ ),
737
+ );
738
+ } else {
739
+ for (const level of expectedLevels) {
740
+ if (!capability.managementResponsibilities[level]) {
741
+ warnings.push(
742
+ createWarning(
743
+ "MISSING_OPTIONAL",
744
+ `Capability missing ${level} management responsibility`,
745
+ `${path}.managementResponsibilities.${level}`,
746
+ ),
747
+ );
748
+ }
749
+ }
750
+ }
751
+
752
+ return { errors, warnings };
753
+ }
754
+
755
+ /**
756
+ * Validate a stage object
757
+ * @param {Object} stage - Stage to validate
758
+ * @param {number} index - Index in the stages array
759
+ * @returns {{errors: Array, warnings: Array}}
760
+ */
761
+ function validateStage(stage, index) {
762
+ const errors = [];
763
+ const warnings = [];
764
+ const path = `stages[${index}]`;
765
+
766
+ if (!stage.id) {
767
+ errors.push(createError("MISSING_REQUIRED", "Stage missing id", path));
768
+ } else if (!Object.values(Stage).includes(stage.id)) {
769
+ errors.push(
770
+ createError(
771
+ "INVALID_VALUE",
772
+ `Invalid stage id: ${stage.id}`,
773
+ `${path}.id`,
774
+ stage.id,
775
+ ),
776
+ );
777
+ }
778
+
779
+ if (!stage.name) {
780
+ errors.push(
781
+ createError("MISSING_REQUIRED", "Stage missing name", `${path}.name`),
782
+ );
783
+ }
784
+
785
+ if (!stage.description) {
786
+ warnings.push(
787
+ createWarning(
788
+ "MISSING_OPTIONAL",
789
+ "Stage missing description",
790
+ `${path}.description`,
791
+ ),
792
+ );
793
+ }
794
+
795
+ // Mode is now inferred from availableTools - no longer required
796
+ // Validate availableTools array
797
+ if (!stage.availableTools || !Array.isArray(stage.availableTools)) {
798
+ errors.push(
799
+ createError(
800
+ "MISSING_REQUIRED",
801
+ "Stage missing availableTools array",
802
+ `${path}.availableTools`,
803
+ ),
804
+ );
805
+ }
806
+
807
+ if (!stage.handoffs || !Array.isArray(stage.handoffs)) {
808
+ warnings.push(
809
+ createWarning(
810
+ "MISSING_OPTIONAL",
811
+ "Stage missing handoffs array",
812
+ `${path}.handoffs`,
813
+ ),
814
+ );
815
+ } else {
816
+ stage.handoffs.forEach((handoff, hIndex) => {
817
+ if (!handoff.targetStage) {
818
+ errors.push(
819
+ createError(
820
+ "MISSING_REQUIRED",
821
+ "Handoff missing targetStage",
822
+ `${path}.handoffs[${hIndex}].targetStage`,
823
+ ),
824
+ );
825
+ }
826
+ if (!handoff.label) {
827
+ errors.push(
828
+ createError(
829
+ "MISSING_REQUIRED",
830
+ "Handoff missing label",
831
+ `${path}.handoffs[${hIndex}].label`,
832
+ ),
833
+ );
834
+ }
835
+ if (!handoff.prompt) {
836
+ errors.push(
837
+ createError(
838
+ "MISSING_REQUIRED",
839
+ "Handoff missing prompt",
840
+ `${path}.handoffs[${hIndex}].prompt`,
841
+ ),
842
+ );
843
+ }
844
+ });
845
+ }
846
+
847
+ return { errors, warnings };
848
+ }
849
+
850
+ /**
851
+ * Validate a self-assessment object
852
+ * @param {import('./levels.js').SelfAssessment} selfAssessment - Self-assessment to validate
853
+ * @param {import('./levels.js').Skill[]} skills - Array of valid skills
854
+ * @param {import('./levels.js').Behaviour[]} behaviours - Array of valid behaviours
855
+ * @returns {import('./levels.js').ValidationResult}
856
+ */
857
+ export function validateSelfAssessment(selfAssessment, skills, behaviours) {
858
+ const errors = [];
859
+ const warnings = [];
860
+ const skillIds = new Set(skills.map((s) => s.id));
861
+ const behaviourIds = new Set(behaviours.map((b) => b.id));
862
+
863
+ if (!selfAssessment) {
864
+ return createValidationResult(false, [
865
+ createError("MISSING_REQUIRED", "Self-assessment is required"),
866
+ ]);
867
+ }
868
+
869
+ // Validate skill assessments
870
+ if (
871
+ !selfAssessment.skillLevels ||
872
+ Object.keys(selfAssessment.skillLevels).length === 0
873
+ ) {
874
+ warnings.push(
875
+ createWarning(
876
+ "MISSING_OPTIONAL",
877
+ "Self-assessment has no skill assessments",
878
+ ),
879
+ );
880
+ } else {
881
+ Object.entries(selfAssessment.skillLevels).forEach(([skillId, level]) => {
882
+ if (!skillIds.has(skillId)) {
883
+ errors.push(
884
+ createError(
885
+ "INVALID_REFERENCE",
886
+ `Self-assessment references non-existent skill: ${skillId}`,
887
+ `selfAssessment.skillLevels.${skillId}`,
888
+ skillId,
889
+ ),
890
+ );
891
+ }
892
+ if (getSkillLevelIndex(level) === -1) {
893
+ errors.push(
894
+ createError(
895
+ "INVALID_VALUE",
896
+ `Self-assessment has invalid skill level for ${skillId}: ${level}`,
897
+ `selfAssessment.skillLevels.${skillId}`,
898
+ level,
899
+ ),
900
+ );
901
+ }
902
+ });
903
+ }
904
+
905
+ // Validate behaviour assessments
906
+ if (
907
+ !selfAssessment.behaviourMaturities ||
908
+ Object.keys(selfAssessment.behaviourMaturities).length === 0
909
+ ) {
910
+ warnings.push(
911
+ createWarning(
912
+ "MISSING_OPTIONAL",
913
+ "Self-assessment has no behaviour assessments",
914
+ ),
915
+ );
916
+ } else {
917
+ Object.entries(selfAssessment.behaviourMaturities).forEach(
918
+ ([behaviourId, maturity]) => {
919
+ if (!behaviourIds.has(behaviourId)) {
920
+ errors.push(
921
+ createError(
922
+ "INVALID_REFERENCE",
923
+ `Self-assessment references non-existent behaviour: ${behaviourId}`,
924
+ `selfAssessment.behaviourMaturities.${behaviourId}`,
925
+ behaviourId,
926
+ ),
927
+ );
928
+ }
929
+ if (getBehaviourMaturityIndex(maturity) === -1) {
930
+ errors.push(
931
+ createError(
932
+ "INVALID_VALUE",
933
+ `Self-assessment has invalid behaviour maturity for ${behaviourId}: ${maturity}`,
934
+ `selfAssessment.behaviourMaturities.${behaviourId}`,
935
+ maturity,
936
+ ),
937
+ );
938
+ }
939
+ },
940
+ );
941
+ }
942
+
943
+ return createValidationResult(errors.length === 0, errors, warnings);
944
+ }
945
+
946
+ /**
947
+ * Validate all data with referential integrity checks
948
+ * @param {Object} data - All data to validate
949
+ * @param {import('./levels.js').Driver[]} data.drivers - Drivers
950
+ * @param {import('./levels.js').Behaviour[]} data.behaviours - Behaviours
951
+ * @param {import('./levels.js').Skill[]} data.skills - Skills
952
+ * @param {import('./levels.js').Discipline[]} data.disciplines - Disciplines
953
+ * @param {import('./levels.js').Track[]} data.tracks - Tracks
954
+ * @param {import('./levels.js').Grade[]} data.grades - Grades
955
+ * @param {Object[]} data.capabilities - Capabilities
956
+ * @param {Object[]} [data.stages] - Stages
957
+ * @returns {import('./levels.js').ValidationResult}
958
+ */
959
+ export function validateAllData({
960
+ drivers,
961
+ behaviours,
962
+ skills,
963
+ disciplines,
964
+ tracks,
965
+ grades,
966
+ capabilities,
967
+ stages,
968
+ }) {
969
+ const allErrors = [];
970
+ const allWarnings = [];
971
+
972
+ // Build ID sets for reference validation
973
+ const skillIds = new Set((skills || []).map((s) => s.id));
974
+ const behaviourIds = new Set((behaviours || []).map((b) => b.id));
975
+ const capabilityIds = new Set((capabilities || []).map((c) => c.id));
976
+
977
+ // Validate skills
978
+ if (!skills || skills.length === 0) {
979
+ allErrors.push(
980
+ createError("MISSING_REQUIRED", "At least one skill is required"),
981
+ );
982
+ } else {
983
+ skills.forEach((skill, index) => {
984
+ const { errors, warnings } = validateSkill(skill, index);
985
+ allErrors.push(...errors);
986
+ allWarnings.push(...warnings);
987
+ });
988
+
989
+ // Check for duplicate IDs
990
+ const seenIds = new Set();
991
+ skills.forEach((skill, index) => {
992
+ if (skill.id) {
993
+ if (seenIds.has(skill.id)) {
994
+ allErrors.push(
995
+ createError(
996
+ "DUPLICATE_ID",
997
+ `Duplicate skill ID: ${skill.id}`,
998
+ `skills[${index}]`,
999
+ skill.id,
1000
+ ),
1001
+ );
1002
+ }
1003
+ seenIds.add(skill.id);
1004
+ }
1005
+ });
1006
+ }
1007
+
1008
+ // Validate behaviours
1009
+ if (!behaviours || behaviours.length === 0) {
1010
+ allErrors.push(
1011
+ createError("MISSING_REQUIRED", "At least one behaviour is required"),
1012
+ );
1013
+ } else {
1014
+ behaviours.forEach((behaviour, index) => {
1015
+ const { errors, warnings } = validateBehaviour(behaviour, index);
1016
+ allErrors.push(...errors);
1017
+ allWarnings.push(...warnings);
1018
+ });
1019
+
1020
+ // Check for duplicate IDs
1021
+ const seenIds = new Set();
1022
+ behaviours.forEach((behaviour, index) => {
1023
+ if (behaviour.id) {
1024
+ if (seenIds.has(behaviour.id)) {
1025
+ allErrors.push(
1026
+ createError(
1027
+ "DUPLICATE_ID",
1028
+ `Duplicate behaviour ID: ${behaviour.id}`,
1029
+ `behaviours[${index}]`,
1030
+ behaviour.id,
1031
+ ),
1032
+ );
1033
+ }
1034
+ seenIds.add(behaviour.id);
1035
+ }
1036
+ });
1037
+ }
1038
+
1039
+ // Validate disciplines
1040
+ if (!disciplines || disciplines.length === 0) {
1041
+ allErrors.push(
1042
+ createError("MISSING_REQUIRED", "At least one discipline is required"),
1043
+ );
1044
+ } else {
1045
+ disciplines.forEach((discipline, index) => {
1046
+ const { errors, warnings } = validateDiscipline(
1047
+ discipline,
1048
+ index,
1049
+ skillIds,
1050
+ behaviourIds,
1051
+ );
1052
+ allErrors.push(...errors);
1053
+ allWarnings.push(...warnings);
1054
+ });
1055
+
1056
+ // Check for duplicate IDs
1057
+ const seenIds = new Set();
1058
+ disciplines.forEach((discipline, index) => {
1059
+ if (discipline.id) {
1060
+ if (seenIds.has(discipline.id)) {
1061
+ allErrors.push(
1062
+ createError(
1063
+ "DUPLICATE_ID",
1064
+ `Duplicate discipline ID: ${discipline.id}`,
1065
+ `disciplines[${index}]`,
1066
+ discipline.id,
1067
+ ),
1068
+ );
1069
+ }
1070
+ seenIds.add(discipline.id);
1071
+ }
1072
+ });
1073
+ }
1074
+
1075
+ // Get all skill IDs from disciplines for track validation
1076
+ const disciplineSkillIds = getAllDisciplineSkillIds(disciplines || []);
1077
+
1078
+ // Get discipline IDs for track validation
1079
+ const disciplineIdSet = new Set((disciplines || []).map((d) => d.id));
1080
+
1081
+ // Get grade IDs for track validation
1082
+ const gradeIdSet = new Set((grades || []).map((g) => g.id));
1083
+
1084
+ // Validate tracks
1085
+ if (!tracks || tracks.length === 0) {
1086
+ allErrors.push(
1087
+ createError("MISSING_REQUIRED", "At least one track is required"),
1088
+ );
1089
+ } else {
1090
+ tracks.forEach((track, index) => {
1091
+ const { errors, warnings } = validateTrack(
1092
+ track,
1093
+ index,
1094
+ disciplineSkillIds,
1095
+ behaviourIds,
1096
+ disciplineIdSet,
1097
+ gradeIdSet,
1098
+ );
1099
+ allErrors.push(...errors);
1100
+ allWarnings.push(...warnings);
1101
+ });
1102
+
1103
+ // Check for duplicate IDs
1104
+ const seenIds = new Set();
1105
+ tracks.forEach((track, index) => {
1106
+ if (track.id) {
1107
+ if (seenIds.has(track.id)) {
1108
+ allErrors.push(
1109
+ createError(
1110
+ "DUPLICATE_ID",
1111
+ `Duplicate track ID: ${track.id}`,
1112
+ `tracks[${index}]`,
1113
+ track.id,
1114
+ ),
1115
+ );
1116
+ }
1117
+ seenIds.add(track.id);
1118
+ }
1119
+ });
1120
+ }
1121
+
1122
+ // Validate grades
1123
+ if (!grades || grades.length === 0) {
1124
+ allErrors.push(
1125
+ createError("MISSING_REQUIRED", "At least one grade is required"),
1126
+ );
1127
+ } else {
1128
+ grades.forEach((grade, index) => {
1129
+ const { errors, warnings } = validateGrade(grade, index);
1130
+ allErrors.push(...errors);
1131
+ allWarnings.push(...warnings);
1132
+ });
1133
+
1134
+ // Check for duplicate IDs
1135
+ const seenIds = new Set();
1136
+ grades.forEach((grade, index) => {
1137
+ if (grade.id) {
1138
+ if (seenIds.has(grade.id)) {
1139
+ allErrors.push(
1140
+ createError(
1141
+ "DUPLICATE_ID",
1142
+ `Duplicate grade ID: ${grade.id}`,
1143
+ `grades[${index}]`,
1144
+ grade.id,
1145
+ ),
1146
+ );
1147
+ }
1148
+ seenIds.add(grade.id);
1149
+ }
1150
+ });
1151
+ }
1152
+
1153
+ // Validate capabilities (required)
1154
+ if (!capabilities || capabilities.length === 0) {
1155
+ allErrors.push(
1156
+ createError("MISSING_REQUIRED", "At least one capability is required"),
1157
+ );
1158
+ } else {
1159
+ capabilities.forEach((capability, index) => {
1160
+ const { errors, warnings } = validateCapability(capability, index);
1161
+ allErrors.push(...errors);
1162
+ allWarnings.push(...warnings);
1163
+ });
1164
+
1165
+ // Check for duplicate IDs
1166
+ const seenIds = new Set();
1167
+ capabilities.forEach((capability, index) => {
1168
+ if (capability.id) {
1169
+ if (seenIds.has(capability.id)) {
1170
+ allErrors.push(
1171
+ createError(
1172
+ "DUPLICATE_ID",
1173
+ `Duplicate capability ID: ${capability.id}`,
1174
+ `capabilities[${index}]`,
1175
+ capability.id,
1176
+ ),
1177
+ );
1178
+ }
1179
+ seenIds.add(capability.id);
1180
+ }
1181
+ });
1182
+
1183
+ // Validate skill capability references against loaded capabilities
1184
+ if (skills && skills.length > 0) {
1185
+ skills.forEach((skill, index) => {
1186
+ if (skill.capability && !capabilityIds.has(skill.capability)) {
1187
+ allErrors.push(
1188
+ createError(
1189
+ "INVALID_REFERENCE",
1190
+ `Skill '${skill.id}' references unknown capability '${skill.capability}'`,
1191
+ `skills[${index}].capability`,
1192
+ skill.capability,
1193
+ ),
1194
+ );
1195
+ }
1196
+ });
1197
+ }
1198
+ }
1199
+
1200
+ // Validate stages (optional but validate if present)
1201
+ if (stages && stages.length > 0) {
1202
+ stages.forEach((stage, index) => {
1203
+ const { errors, warnings } = validateStage(stage, index);
1204
+ allErrors.push(...errors);
1205
+ allWarnings.push(...warnings);
1206
+ });
1207
+
1208
+ // Check for duplicate IDs
1209
+ const seenIds = new Set();
1210
+ stages.forEach((stage, index) => {
1211
+ if (stage.id) {
1212
+ if (seenIds.has(stage.id)) {
1213
+ allErrors.push(
1214
+ createError(
1215
+ "DUPLICATE_ID",
1216
+ `Duplicate stage ID: ${stage.id}`,
1217
+ `stages[${index}]`,
1218
+ stage.id,
1219
+ ),
1220
+ );
1221
+ }
1222
+ seenIds.add(stage.id);
1223
+ }
1224
+ });
1225
+
1226
+ // Validate handoff targets reference valid stages
1227
+ const stageIds = new Set(stages.map((s) => s.id));
1228
+ stages.forEach((stage, sIndex) => {
1229
+ if (stage.handoffs) {
1230
+ stage.handoffs.forEach((handoff, hIndex) => {
1231
+ if (handoff.target && !stageIds.has(handoff.target)) {
1232
+ allErrors.push(
1233
+ createError(
1234
+ "INVALID_REFERENCE",
1235
+ `Stage '${stage.id}' handoff references unknown stage '${handoff.target}'`,
1236
+ `stages[${sIndex}].handoffs[${hIndex}].target`,
1237
+ handoff.target,
1238
+ ),
1239
+ );
1240
+ }
1241
+ });
1242
+ }
1243
+ });
1244
+ }
1245
+
1246
+ // Validate drivers (required)
1247
+ if (!drivers || drivers.length === 0) {
1248
+ allErrors.push(
1249
+ createError("MISSING_REQUIRED", "At least one driver is required"),
1250
+ );
1251
+ } else {
1252
+ drivers.forEach((driver, index) => {
1253
+ const { errors, warnings } = validateDriver(
1254
+ driver,
1255
+ index,
1256
+ skillIds,
1257
+ behaviourIds,
1258
+ );
1259
+ allErrors.push(...errors);
1260
+ allWarnings.push(...warnings);
1261
+ });
1262
+
1263
+ // Check for duplicate IDs
1264
+ const seenIds = new Set();
1265
+ drivers.forEach((driver, index) => {
1266
+ if (driver.id) {
1267
+ if (seenIds.has(driver.id)) {
1268
+ allErrors.push(
1269
+ createError(
1270
+ "DUPLICATE_ID",
1271
+ `Duplicate driver ID: ${driver.id}`,
1272
+ `drivers[${index}]`,
1273
+ driver.id,
1274
+ ),
1275
+ );
1276
+ }
1277
+ seenIds.add(driver.id);
1278
+ }
1279
+ });
1280
+ }
1281
+
1282
+ return createValidationResult(allErrors.length === 0, allErrors, allWarnings);
1283
+ }
1284
+
1285
+ /**
1286
+ * Validate question bank structure
1287
+ * @param {import('./levels.js').QuestionBank} questionBank - Question bank to validate
1288
+ * @param {import('./levels.js').Skill[]} skills - Valid skills
1289
+ * @param {import('./levels.js').Behaviour[]} behaviours - Valid behaviours
1290
+ * @returns {import('./levels.js').ValidationResult}
1291
+ */
1292
+ export function validateQuestionBank(questionBank, skills, behaviours) {
1293
+ const errors = [];
1294
+ const warnings = [];
1295
+ const skillIds = new Set(skills.map((s) => s.id));
1296
+ const behaviourIds = new Set(behaviours.map((b) => b.id));
1297
+
1298
+ if (!questionBank) {
1299
+ return createValidationResult(false, [
1300
+ createError("MISSING_REQUIRED", "Question bank is required"),
1301
+ ]);
1302
+ }
1303
+
1304
+ // Validate skill questions
1305
+ if (questionBank.skillLevels) {
1306
+ Object.entries(questionBank.skillLevels).forEach(
1307
+ ([skillId, levelQuestions]) => {
1308
+ if (!skillIds.has(skillId)) {
1309
+ errors.push(
1310
+ createError(
1311
+ "INVALID_REFERENCE",
1312
+ `Question bank references non-existent skill: ${skillId}`,
1313
+ `questionBank.skillLevels.${skillId}`,
1314
+ skillId,
1315
+ ),
1316
+ );
1317
+ }
1318
+ Object.entries(levelQuestions || {}).forEach(([level, questions]) => {
1319
+ if (getSkillLevelIndex(level) === -1) {
1320
+ errors.push(
1321
+ createError(
1322
+ "INVALID_VALUE",
1323
+ `Question bank has invalid skill level: ${level}`,
1324
+ `questionBank.skillLevels.${skillId}.${level}`,
1325
+ level,
1326
+ ),
1327
+ );
1328
+ }
1329
+ if (!Array.isArray(questions) || questions.length === 0) {
1330
+ warnings.push(
1331
+ createWarning(
1332
+ "EMPTY_QUESTIONS",
1333
+ `No questions for skill ${skillId} at level ${level}`,
1334
+ `questionBank.skillLevels.${skillId}.${level}`,
1335
+ ),
1336
+ );
1337
+ }
1338
+ });
1339
+ },
1340
+ );
1341
+ }
1342
+
1343
+ // Validate behaviour questions
1344
+ if (questionBank.behaviourMaturities) {
1345
+ Object.entries(questionBank.behaviourMaturities).forEach(
1346
+ ([behaviourId, maturityQuestions]) => {
1347
+ if (!behaviourIds.has(behaviourId)) {
1348
+ errors.push(
1349
+ createError(
1350
+ "INVALID_REFERENCE",
1351
+ `Question bank references non-existent behaviour: ${behaviourId}`,
1352
+ `questionBank.behaviourMaturities.${behaviourId}`,
1353
+ behaviourId,
1354
+ ),
1355
+ );
1356
+ }
1357
+ Object.entries(maturityQuestions || {}).forEach(
1358
+ ([maturity, questions]) => {
1359
+ if (getBehaviourMaturityIndex(maturity) === -1) {
1360
+ errors.push(
1361
+ createError(
1362
+ "INVALID_VALUE",
1363
+ `Question bank has invalid behaviour maturity: ${maturity}`,
1364
+ `questionBank.behaviourMaturities.${behaviourId}.${maturity}`,
1365
+ maturity,
1366
+ ),
1367
+ );
1368
+ }
1369
+ if (!Array.isArray(questions) || questions.length === 0) {
1370
+ warnings.push(
1371
+ createWarning(
1372
+ "EMPTY_QUESTIONS",
1373
+ `No questions for behaviour ${behaviourId} at maturity ${maturity}`,
1374
+ `questionBank.behaviourMaturities.${behaviourId}.${maturity}`,
1375
+ ),
1376
+ );
1377
+ }
1378
+ },
1379
+ );
1380
+ },
1381
+ );
1382
+ }
1383
+
1384
+ return createValidationResult(errors.length === 0, errors, warnings);
1385
+ }