@forwardimpact/schema 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 (65) hide show
  1. package/bin/fit-schema.js +260 -0
  2. package/examples/behaviours/_index.yaml +8 -0
  3. package/examples/behaviours/outcome_ownership.yaml +43 -0
  4. package/examples/behaviours/polymathic_knowledge.yaml +41 -0
  5. package/examples/behaviours/precise_communication.yaml +39 -0
  6. package/examples/behaviours/relentless_curiosity.yaml +37 -0
  7. package/examples/behaviours/systems_thinking.yaml +40 -0
  8. package/examples/capabilities/_index.yaml +8 -0
  9. package/examples/capabilities/business.yaml +189 -0
  10. package/examples/capabilities/delivery.yaml +305 -0
  11. package/examples/capabilities/people.yaml +68 -0
  12. package/examples/capabilities/reliability.yaml +414 -0
  13. package/examples/capabilities/scale.yaml +378 -0
  14. package/examples/copilot-setup-steps.yaml +25 -0
  15. package/examples/devcontainer.yaml +21 -0
  16. package/examples/disciplines/_index.yaml +6 -0
  17. package/examples/disciplines/data_engineering.yaml +78 -0
  18. package/examples/disciplines/engineering_management.yaml +63 -0
  19. package/examples/disciplines/software_engineering.yaml +78 -0
  20. package/examples/drivers.yaml +202 -0
  21. package/examples/framework.yaml +69 -0
  22. package/examples/grades.yaml +115 -0
  23. package/examples/questions/behaviours/outcome_ownership.yaml +51 -0
  24. package/examples/questions/behaviours/polymathic_knowledge.yaml +47 -0
  25. package/examples/questions/behaviours/precise_communication.yaml +54 -0
  26. package/examples/questions/behaviours/relentless_curiosity.yaml +50 -0
  27. package/examples/questions/behaviours/systems_thinking.yaml +52 -0
  28. package/examples/questions/skills/architecture_design.yaml +53 -0
  29. package/examples/questions/skills/cloud_platforms.yaml +47 -0
  30. package/examples/questions/skills/code_quality.yaml +48 -0
  31. package/examples/questions/skills/data_modeling.yaml +45 -0
  32. package/examples/questions/skills/devops.yaml +46 -0
  33. package/examples/questions/skills/full_stack_development.yaml +47 -0
  34. package/examples/questions/skills/sre_practices.yaml +43 -0
  35. package/examples/questions/skills/stakeholder_management.yaml +48 -0
  36. package/examples/questions/skills/team_collaboration.yaml +42 -0
  37. package/examples/questions/skills/technical_writing.yaml +42 -0
  38. package/examples/self-assessments.yaml +64 -0
  39. package/examples/stages.yaml +139 -0
  40. package/examples/tracks/_index.yaml +5 -0
  41. package/examples/tracks/platform.yaml +49 -0
  42. package/examples/tracks/sre.yaml +48 -0
  43. package/examples/vscode-settings.yaml +21 -0
  44. package/lib/index-generator.js +65 -0
  45. package/lib/index.js +44 -0
  46. package/lib/levels.js +601 -0
  47. package/lib/loader.js +599 -0
  48. package/lib/modifiers.js +23 -0
  49. package/lib/schema-validation.js +438 -0
  50. package/lib/validation.js +2130 -0
  51. package/package.json +49 -0
  52. package/schema/json/behaviour-questions.schema.json +68 -0
  53. package/schema/json/behaviour.schema.json +73 -0
  54. package/schema/json/capability.schema.json +220 -0
  55. package/schema/json/defs.schema.json +132 -0
  56. package/schema/json/discipline.schema.json +132 -0
  57. package/schema/json/drivers.schema.json +48 -0
  58. package/schema/json/framework.schema.json +55 -0
  59. package/schema/json/grades.schema.json +121 -0
  60. package/schema/json/index.schema.json +18 -0
  61. package/schema/json/self-assessments.schema.json +52 -0
  62. package/schema/json/skill-questions.schema.json +68 -0
  63. package/schema/json/stages.schema.json +84 -0
  64. package/schema/json/track.schema.json +100 -0
  65. package/schema/rdf/pathway.ttl +2362 -0
@@ -0,0 +1,438 @@
1
+ /**
2
+ * Schema-based Validation for Engineering Pathway
3
+ *
4
+ * Validates YAML data files against JSON schemas using Ajv.
5
+ * Replaces custom validation with declarative schema validation.
6
+ */
7
+
8
+ import { readFile, readdir, stat } from "fs/promises";
9
+ import { parse as parseYaml } from "yaml";
10
+ import { join, dirname } from "path";
11
+ import { fileURLToPath } from "url";
12
+ import Ajv from "ajv";
13
+ import addFormats from "ajv-formats";
14
+
15
+ const __filename = fileURLToPath(import.meta.url);
16
+ const __dirname = dirname(__filename);
17
+ const schemaDir = join(__dirname, "../schema/json");
18
+
19
+ /**
20
+ * Schema mappings for different file types
21
+ * Maps directory/file patterns to schema files
22
+ */
23
+ const SCHEMA_MAPPINGS = {
24
+ // Single files at root of data directory
25
+ "drivers.yaml": "drivers.schema.json",
26
+ "grades.yaml": "grades.schema.json",
27
+ "stages.yaml": "stages.schema.json",
28
+ "framework.yaml": "framework.schema.json",
29
+ "self-assessments.yaml": "self-assessments.schema.json",
30
+ // Directories - each file in directory uses the schema
31
+ capabilities: "capability.schema.json",
32
+ disciplines: "discipline.schema.json",
33
+ tracks: "track.schema.json",
34
+ behaviours: "behaviour.schema.json",
35
+ "questions/skills": "skill-questions.schema.json",
36
+ "questions/behaviours": "behaviour-questions.schema.json",
37
+ };
38
+
39
+ /**
40
+ * Create a validation result object
41
+ * @param {boolean} valid - Whether validation passed
42
+ * @param {Array<{type: string, message: string, path?: string}>} errors - Array of errors
43
+ * @param {Array<{type: string, message: string, path?: string}>} warnings - Array of warnings
44
+ * @returns {{valid: boolean, errors: Array, warnings: Array}}
45
+ */
46
+ function createValidationResult(valid, errors = [], warnings = []) {
47
+ return { valid, errors, warnings };
48
+ }
49
+
50
+ /**
51
+ * Create a validation error
52
+ * @param {string} type - Error type
53
+ * @param {string} message - Error message
54
+ * @param {string} [path] - Path to invalid data
55
+ * @returns {{type: string, message: string, path?: string}}
56
+ */
57
+ function createError(type, message, path) {
58
+ const error = { type, message };
59
+ if (path !== undefined) error.path = path;
60
+ return error;
61
+ }
62
+
63
+ /**
64
+ * Create a validation warning
65
+ * @param {string} type - Warning type
66
+ * @param {string} message - Warning message
67
+ * @param {string} [path] - Path to concerning data
68
+ * @returns {{type: string, message: string, path?: string}}
69
+ */
70
+ function createWarning(type, message, path) {
71
+ const warning = { type, message };
72
+ if (path !== undefined) warning.path = path;
73
+ return warning;
74
+ }
75
+
76
+ /**
77
+ * Check if a path exists and is a directory
78
+ * @param {string} path - Path to check
79
+ * @returns {Promise<boolean>}
80
+ */
81
+ async function isDirectory(path) {
82
+ try {
83
+ const stats = await stat(path);
84
+ return stats.isDirectory();
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Check if a file exists
92
+ * @param {string} path - Path to check
93
+ * @returns {Promise<boolean>}
94
+ */
95
+ async function fileExists(path) {
96
+ try {
97
+ await stat(path);
98
+ return true;
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Load and parse a JSON schema
106
+ * @param {string} schemaPath - Path to the schema file
107
+ * @returns {Promise<Object>} Parsed schema
108
+ */
109
+ async function loadSchema(schemaPath) {
110
+ const content = await readFile(schemaPath, "utf-8");
111
+ return JSON.parse(content);
112
+ }
113
+
114
+ /**
115
+ * Load and parse a YAML file
116
+ * @param {string} filePath - Path to the YAML file
117
+ * @returns {Promise<any>} Parsed YAML content
118
+ */
119
+ async function loadYamlFile(filePath) {
120
+ const content = await readFile(filePath, "utf-8");
121
+ return parseYaml(content);
122
+ }
123
+
124
+ /**
125
+ * Format Ajv errors into readable messages
126
+ * @param {import('ajv').ErrorObject[]} ajvErrors - Ajv error objects
127
+ * @param {string} filePath - File being validated
128
+ * @returns {Array<{type: string, message: string, path?: string}>}
129
+ */
130
+ function formatAjvErrors(ajvErrors, filePath) {
131
+ return ajvErrors.map((err) => {
132
+ const path = err.instancePath ? `${filePath}${err.instancePath}` : filePath;
133
+ let message = err.message || "Unknown error";
134
+
135
+ // Add context for specific error types
136
+ if (err.keyword === "additionalProperties") {
137
+ message = `${message}: '${err.params.additionalProperty}'`;
138
+ } else if (err.keyword === "enum") {
139
+ message = `${message}. Allowed: ${err.params.allowedValues.join(", ")}`;
140
+ } else if (err.keyword === "pattern") {
141
+ message = `${message}. Pattern: ${err.params.pattern}`;
142
+ }
143
+
144
+ return createError("SCHEMA_VALIDATION", message, path);
145
+ });
146
+ }
147
+
148
+ /**
149
+ * Create and configure an Ajv instance with all schemas loaded
150
+ * @returns {Promise<Ajv>}
151
+ */
152
+ async function createValidator() {
153
+ const ajv = new Ajv({
154
+ allErrors: true,
155
+ strict: false,
156
+ validateFormats: true,
157
+ });
158
+ addFormats(ajv);
159
+
160
+ // Load all schema files
161
+ const schemaFiles = await readdir(schemaDir);
162
+ for (const file of schemaFiles.filter((f) => f.endsWith(".schema.json"))) {
163
+ const schema = await loadSchema(join(schemaDir, file));
164
+ ajv.addSchema(schema);
165
+ }
166
+
167
+ return ajv;
168
+ }
169
+
170
+ /**
171
+ * Validate a single file against a schema
172
+ * @param {Ajv} ajv - Configured Ajv instance
173
+ * @param {string} filePath - Path to the YAML file
174
+ * @param {string} schemaId - Schema $id to validate against
175
+ * @returns {Promise<{valid: boolean, errors: Array}>}
176
+ */
177
+ async function validateFile(ajv, filePath, schemaId) {
178
+ const data = await loadYamlFile(filePath);
179
+ const validate = ajv.getSchema(schemaId);
180
+
181
+ if (!validate) {
182
+ return {
183
+ valid: false,
184
+ errors: [
185
+ createError("SCHEMA_NOT_FOUND", `Schema not found: ${schemaId}`),
186
+ ],
187
+ };
188
+ }
189
+
190
+ const valid = validate(data);
191
+ const errors = valid ? [] : formatAjvErrors(validate.errors || [], filePath);
192
+
193
+ return { valid, errors };
194
+ }
195
+
196
+ /**
197
+ * Validate all files in a directory against a schema
198
+ * @param {Ajv} ajv - Configured Ajv instance
199
+ * @param {string} dirPath - Path to the directory
200
+ * @param {string} schemaId - Schema $id to validate against
201
+ * @returns {Promise<{valid: boolean, errors: Array}>}
202
+ */
203
+ async function validateDirectory(ajv, dirPath, schemaId) {
204
+ const files = await readdir(dirPath);
205
+ const yamlFiles = files.filter(
206
+ (f) => f.endsWith(".yaml") && !f.startsWith("_"),
207
+ );
208
+
209
+ const allErrors = [];
210
+ let allValid = true;
211
+
212
+ for (const file of yamlFiles) {
213
+ const filePath = join(dirPath, file);
214
+ const result = await validateFile(ajv, filePath, schemaId);
215
+ if (!result.valid) {
216
+ allValid = false;
217
+ allErrors.push(...result.errors);
218
+ }
219
+ }
220
+
221
+ return { valid: allValid, errors: allErrors };
222
+ }
223
+
224
+ /**
225
+ * Build the schema $id from the schema filename
226
+ * @param {string} schemaFilename - Schema filename (e.g., "capability.schema.json")
227
+ * @returns {string} Full schema $id URL
228
+ */
229
+ function getSchemaId(schemaFilename) {
230
+ return `https://schema.forwardimpact.team/json/${schemaFilename}`;
231
+ }
232
+
233
+ /**
234
+ * Validate a data directory against JSON schemas
235
+ * @param {string} dataDir - Path to the data directory
236
+ * @returns {Promise<{valid: boolean, errors: Array, warnings: Array, stats: Object}>}
237
+ */
238
+ export async function validateDataDirectory(dataDir) {
239
+ const ajv = await createValidator();
240
+ const allErrors = [];
241
+ const warnings = [];
242
+ const stats = {
243
+ filesValidated: 0,
244
+ schemasUsed: new Set(),
245
+ };
246
+
247
+ // Validate single files at root level
248
+ for (const [filename, schemaFile] of Object.entries(SCHEMA_MAPPINGS)) {
249
+ // Skip directory mappings
250
+ if (!filename.includes(".yaml")) continue;
251
+
252
+ const filePath = join(dataDir, filename);
253
+ if (!(await fileExists(filePath))) {
254
+ // Some files are optional
255
+ if (!["self-assessments.yaml"].includes(filename)) {
256
+ warnings.push(
257
+ createWarning("MISSING_FILE", `Optional file not found: ${filename}`),
258
+ );
259
+ }
260
+ continue;
261
+ }
262
+
263
+ const schemaId = getSchemaId(schemaFile);
264
+ const result = await validateFile(ajv, filePath, schemaId);
265
+ stats.filesValidated++;
266
+ stats.schemasUsed.add(schemaFile);
267
+
268
+ if (!result.valid) {
269
+ allErrors.push(...result.errors);
270
+ }
271
+ }
272
+
273
+ // Validate directories
274
+ for (const [dirName, schemaFile] of Object.entries(SCHEMA_MAPPINGS)) {
275
+ // Skip single file mappings
276
+ if (dirName.includes(".yaml")) continue;
277
+
278
+ const dirPath = join(dataDir, dirName);
279
+ if (!(await isDirectory(dirPath))) {
280
+ continue;
281
+ }
282
+
283
+ const schemaId = getSchemaId(schemaFile);
284
+ const result = await validateDirectory(ajv, dirPath, schemaId);
285
+
286
+ // Count files
287
+ const files = await readdir(dirPath);
288
+ const yamlFiles = files.filter(
289
+ (f) => f.endsWith(".yaml") && !f.startsWith("_"),
290
+ );
291
+ stats.filesValidated += yamlFiles.length;
292
+ stats.schemasUsed.add(schemaFile);
293
+
294
+ if (!result.valid) {
295
+ allErrors.push(...result.errors);
296
+ }
297
+ }
298
+
299
+ return createValidationResult(allErrors.length === 0, allErrors, warnings);
300
+ }
301
+
302
+ /**
303
+ * Validate referential integrity (skill/behaviour references exist)
304
+ * This supplements schema validation with cross-file reference checks
305
+ * @param {Object} data - Loaded data object
306
+ * @param {Array} data.skills - Skills
307
+ * @param {Array} data.behaviours - Behaviours
308
+ * @param {Array} data.disciplines - Disciplines
309
+ * @param {Array} data.drivers - Drivers
310
+ * @param {Array} data.capabilities - Capabilities
311
+ * @returns {{valid: boolean, errors: Array, warnings: Array}}
312
+ */
313
+ export function validateReferentialIntegrity(data) {
314
+ const errors = [];
315
+ const warnings = [];
316
+
317
+ const skillIds = new Set((data.skills || []).map((s) => s.id));
318
+ const behaviourIds = new Set((data.behaviours || []).map((b) => b.id));
319
+ const capabilityIds = new Set((data.capabilities || []).map((c) => c.id));
320
+
321
+ // Validate discipline skill references
322
+ for (const discipline of data.disciplines || []) {
323
+ const allSkillRefs = [
324
+ ...(discipline.coreSkills || []),
325
+ ...(discipline.supportingSkills || []),
326
+ ...(discipline.broadSkills || []),
327
+ ];
328
+
329
+ for (const skillId of allSkillRefs) {
330
+ if (!skillIds.has(skillId)) {
331
+ errors.push(
332
+ createError(
333
+ "INVALID_REFERENCE",
334
+ `Discipline '${discipline.id}' references unknown skill '${skillId}'`,
335
+ `disciplines/${discipline.id}`,
336
+ ),
337
+ );
338
+ }
339
+ }
340
+
341
+ // Validate behaviour modifier references
342
+ for (const behaviourId of Object.keys(
343
+ discipline.behaviourModifiers || {},
344
+ )) {
345
+ if (!behaviourIds.has(behaviourId)) {
346
+ errors.push(
347
+ createError(
348
+ "INVALID_REFERENCE",
349
+ `Discipline '${discipline.id}' references unknown behaviour '${behaviourId}'`,
350
+ `disciplines/${discipline.id}`,
351
+ ),
352
+ );
353
+ }
354
+ }
355
+ }
356
+
357
+ // Validate track skill modifier references (should reference capabilities, not skills)
358
+ for (const track of data.tracks || []) {
359
+ for (const capabilityId of Object.keys(track.skillModifiers || {})) {
360
+ if (!capabilityIds.has(capabilityId)) {
361
+ errors.push(
362
+ createError(
363
+ "INVALID_REFERENCE",
364
+ `Track '${track.id}' references unknown capability '${capabilityId}'`,
365
+ `tracks/${track.id}`,
366
+ ),
367
+ );
368
+ }
369
+ }
370
+
371
+ // Validate behaviour modifier references
372
+ for (const behaviourId of Object.keys(track.behaviourModifiers || {})) {
373
+ if (!behaviourIds.has(behaviourId)) {
374
+ errors.push(
375
+ createError(
376
+ "INVALID_REFERENCE",
377
+ `Track '${track.id}' references unknown behaviour '${behaviourId}'`,
378
+ `tracks/${track.id}`,
379
+ ),
380
+ );
381
+ }
382
+ }
383
+ }
384
+
385
+ // Validate driver skill/behaviour references
386
+ for (const driver of data.drivers || []) {
387
+ for (const skillId of driver.contributingSkills || []) {
388
+ if (!skillIds.has(skillId)) {
389
+ errors.push(
390
+ createError(
391
+ "INVALID_REFERENCE",
392
+ `Driver '${driver.id}' references unknown skill '${skillId}'`,
393
+ `drivers`,
394
+ ),
395
+ );
396
+ }
397
+ }
398
+
399
+ for (const behaviourId of driver.contributingBehaviours || []) {
400
+ if (!behaviourIds.has(behaviourId)) {
401
+ errors.push(
402
+ createError(
403
+ "INVALID_REFERENCE",
404
+ `Driver '${driver.id}' references unknown behaviour '${behaviourId}'`,
405
+ `drivers`,
406
+ ),
407
+ );
408
+ }
409
+ }
410
+ }
411
+
412
+ return createValidationResult(errors.length === 0, errors, warnings);
413
+ }
414
+
415
+ /**
416
+ * Run full validation: schema validation + referential integrity
417
+ * @param {string} dataDir - Path to the data directory
418
+ * @param {Object} [loadedData] - Pre-loaded data (if available, skips schema validation stats gathering)
419
+ * @returns {Promise<{valid: boolean, errors: Array, warnings: Array}>}
420
+ */
421
+ export async function runSchemaValidation(dataDir, loadedData) {
422
+ const allErrors = [];
423
+ const allWarnings = [];
424
+
425
+ // Run schema validation
426
+ const schemaResult = await validateDataDirectory(dataDir);
427
+ allErrors.push(...schemaResult.errors);
428
+ allWarnings.push(...schemaResult.warnings);
429
+
430
+ // If we have loaded data, also check referential integrity
431
+ if (loadedData) {
432
+ const refResult = validateReferentialIntegrity(loadedData);
433
+ allErrors.push(...refResult.errors);
434
+ allWarnings.push(...refResult.warnings);
435
+ }
436
+
437
+ return createValidationResult(allErrors.length === 0, allErrors, allWarnings);
438
+ }