@onboardingiq/delivery-model-validator 0.0.1 → 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.
@@ -1 +1 @@
1
- {"version":3,"file":"validateDeliveryModel.d.ts","sourceRoot":"","sources":["../src/validateDeliveryModel.ts"],"names":[],"mappings":"AAKA,KAAK,WAAW,GAAG;IACjB,EAAE,EAAE,MAAM,GAAG,IAAI,CAAC;IAClB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB,CAAC;AA0BF,MAAM,MAAM,+BAA+B,GAAG;IAC5C,MAAM,EAAE,IAAI,GAAG,OAAO,CAAC;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,MAAM,EAAE,WAAW,EAAE,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB,CAAC;AAmKF,wBAAsB,2BAA2B,CAAC,MAAM,EAAE;IACxD,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB,GAAG,OAAO,CAAC,+BAA+B,CAAC,CAyL3C"}
1
+ {"version":3,"file":"validateDeliveryModel.d.ts","sourceRoot":"","sources":["../src/validateDeliveryModel.ts"],"names":[],"mappings":"AAgBA,KAAK,WAAW,GAAG;IACjB,EAAE,EAAE,MAAM,GAAG,IAAI,CAAC;IAClB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB,CAAC;AA0BF,MAAM,MAAM,+BAA+B,GAAG;IAC5C,MAAM,EAAE,IAAI,GAAG,OAAO,CAAC;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,MAAM,EAAE,WAAW,EAAE,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB,CAAC;AA+NF,wBAAsB,2BAA2B,CAAC,MAAM,EAAE;IACxD,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB,GAAG,OAAO,CAAC,+BAA+B,CAAC,CAmO3C"}
@@ -8,6 +8,15 @@ const promises_1 = require("node:fs/promises");
8
8
  const node_path_1 = __importDefault(require("node:path"));
9
9
  const js_yaml_1 = __importDefault(require("js-yaml"));
10
10
  const gray_matter_1 = __importDefault(require("gray-matter"));
11
+ const VALID_CANON_ASSET_TYPES = [
12
+ "Framework",
13
+ "Checklist",
14
+ "SOP",
15
+ "Guide",
16
+ "Reference",
17
+ "Template",
18
+ ];
19
+ const VALID_CANON_ASSET_TYPES_LABEL = VALID_CANON_ASSET_TYPES.join(", ");
11
20
  function phaseSummary(phases) {
12
21
  if (!Array.isArray(phases))
13
22
  return [];
@@ -22,6 +31,32 @@ function phaseSummary(phases) {
22
31
  };
23
32
  });
24
33
  }
34
+ function describeValueType(value) {
35
+ if (value === null)
36
+ return "null";
37
+ if (Array.isArray(value))
38
+ return "array";
39
+ if (typeof value === "object")
40
+ return "object";
41
+ return typeof value;
42
+ }
43
+ /** Required non-empty string from frontmatter; pushes precise errors and returns null on failure. */
44
+ function pushRequiredString(errors, filePath, entity, field, value) {
45
+ if (value === undefined || value === null) {
46
+ errors.push(`${entity} ${filePath}: missing required field: ${field}`);
47
+ return null;
48
+ }
49
+ if (typeof value !== "string") {
50
+ errors.push(`${entity} ${filePath}: field ${field} must be a non-empty string (got ${describeValueType(value)})`);
51
+ return null;
52
+ }
53
+ const trimmed = value.trim();
54
+ if (!trimmed) {
55
+ errors.push(`${entity} ${filePath}: field ${field} must be non-empty`);
56
+ return null;
57
+ }
58
+ return trimmed;
59
+ }
25
60
  function asNonEmptyString(value) {
26
61
  if (typeof value !== "string")
27
62
  return null;
@@ -59,8 +94,9 @@ async function parseTasks(tasksRoot, debug) {
59
94
  try {
60
95
  files = (await walkFiles(tasksRoot)).filter((file) => file.toLowerCase().endsWith(".md"));
61
96
  }
62
- catch {
63
- taskErrors.push(`Tasks directory not found: ${tasksRoot}`);
97
+ catch (error) {
98
+ const message = error instanceof Error ? error.message : "unknown error";
99
+ taskErrors.push(`Unable to read tasks directory ${tasksRoot}: ${message}`);
64
100
  return { taskCount: 0, tasks, taskErrors };
65
101
  }
66
102
  debugLog(debug, "[git-validate] tasks discovered", { taskCount: files.length, tasksRoot });
@@ -68,20 +104,25 @@ async function parseTasks(tasksRoot, debug) {
68
104
  try {
69
105
  const raw = await (0, promises_1.readFile)(filePath, "utf8");
70
106
  const frontmatter = (0, gray_matter_1.default)(raw).data;
71
- const id = asNonEmptyString(frontmatter.id);
72
- const title = asNonEmptyString(frontmatter.title);
73
- const phase = asNonEmptyString(frontmatter.phase);
107
+ const id = pushRequiredString(taskErrors, filePath, "Task", "id", frontmatter.id);
108
+ const title = pushRequiredString(taskErrors, filePath, "Task", "title", frontmatter.title);
109
+ const phase = pushRequiredString(taskErrors, filePath, "Task", "phase", frontmatter.phase);
74
110
  const canonRefs = [];
75
111
  const canonField = frontmatter.canon;
76
112
  if (canonField !== undefined) {
77
113
  if (!Array.isArray(canonField)) {
78
- taskErrors.push(`Task ${filePath} has invalid canon field: expected array`);
114
+ taskErrors.push(`Task ${filePath}: field canon must be an array`);
79
115
  }
80
116
  else {
81
117
  for (const item of canonField) {
82
118
  const ref = asNonEmptyString(item);
83
119
  if (!ref) {
84
- taskErrors.push(`Task ${filePath} has invalid canon entry: expected non-empty string`);
120
+ if (typeof item === "string" && !item.trim()) {
121
+ taskErrors.push(`Task ${filePath}: field canon must contain only non-empty strings`);
122
+ }
123
+ else {
124
+ taskErrors.push(`Task ${filePath}: field canon must contain only non-empty strings (got ${describeValueType(item)})`);
125
+ }
85
126
  }
86
127
  else {
87
128
  canonRefs.push(ref);
@@ -89,12 +130,6 @@ async function parseTasks(tasksRoot, debug) {
89
130
  }
90
131
  }
91
132
  }
92
- if (!id)
93
- taskErrors.push(`Task ${filePath} is missing required field: id`);
94
- if (!title)
95
- taskErrors.push(`Task ${filePath} is missing required field: title`);
96
- if (!phase)
97
- taskErrors.push(`Task ${filePath} is missing required field: phase`);
98
133
  tasks.push({ id, title, phase, canonRefs, filePath });
99
134
  }
100
135
  catch (error) {
@@ -102,11 +137,15 @@ async function parseTasks(tasksRoot, debug) {
102
137
  taskErrors.push(`Task ${filePath} parse failed: ${message}`);
103
138
  }
104
139
  }
105
- const duplicateTaskIds = duplicateValues(tasks.map((task) => task.id).filter((id) => Boolean(id)));
140
+ const duplicateTaskIds = duplicateValues(tasks.map((task) => task.id).filter((tid) => Boolean(tid)));
106
141
  if (duplicateTaskIds.length > 0) {
107
142
  debugLog(debug, "[git-validate] duplicate task ids", { duplicateTaskIds });
108
143
  for (const duplicateId of duplicateTaskIds) {
109
- taskErrors.push(`Duplicate task id found: ${duplicateId}`);
144
+ const paths = tasks
145
+ .filter((task) => task.id === duplicateId)
146
+ .map((task) => task.filePath)
147
+ .sort();
148
+ taskErrors.push(`Duplicate task id "${duplicateId}" in files: ${paths.join(", ")}`);
110
149
  }
111
150
  }
112
151
  return { taskCount: files.length, tasks, taskErrors };
@@ -116,10 +155,11 @@ async function parseCanon(canonRoot, debug) {
116
155
  const canonAssets = [];
117
156
  let files = [];
118
157
  try {
119
- files = await walkFiles(canonRoot);
158
+ files = (await walkFiles(canonRoot)).filter((file) => file.toLowerCase().endsWith(".md"));
120
159
  }
121
- catch {
122
- canonErrors.push(`Canon directory not found: ${canonRoot}`);
160
+ catch (error) {
161
+ const message = error instanceof Error ? error.message : "unknown error";
162
+ canonErrors.push(`Unable to read canon directory ${canonRoot}: ${message}`);
123
163
  return { canonCount: 0, canonAssets, canonErrors };
124
164
  }
125
165
  debugLog(debug, "[git-validate] canon discovered", { canonCount: files.length, canonRoot });
@@ -127,18 +167,29 @@ async function parseCanon(canonRoot, debug) {
127
167
  try {
128
168
  const raw = await (0, promises_1.readFile)(filePath, "utf8");
129
169
  const frontmatter = (0, gray_matter_1.default)(raw).data;
130
- const id = asNonEmptyString(frontmatter.id);
131
- const title = asNonEmptyString(frontmatter.title);
132
- const summary = asNonEmptyString(frontmatter.summary);
133
- const assetType = asNonEmptyString(frontmatter.asset_type);
134
- if (!id)
135
- canonErrors.push(`Canon ${filePath} is missing required field: id`);
136
- if (!title)
137
- canonErrors.push(`Canon ${filePath} is missing required field: title`);
138
- if (!summary)
139
- canonErrors.push(`Canon ${filePath} is missing required field: summary`);
140
- if (!assetType)
141
- canonErrors.push(`Canon ${filePath} is missing required field: asset_type`);
170
+ const id = pushRequiredString(canonErrors, filePath, "Canon", "id", frontmatter.id);
171
+ const title = pushRequiredString(canonErrors, filePath, "Canon", "title", frontmatter.title);
172
+ const summary = pushRequiredString(canonErrors, filePath, "Canon", "summary", frontmatter.summary);
173
+ let assetType = null;
174
+ const rawAssetType = frontmatter.asset_type;
175
+ if (rawAssetType === undefined || rawAssetType === null) {
176
+ canonErrors.push(`Canon ${filePath}: missing required field: asset_type`);
177
+ }
178
+ else if (typeof rawAssetType !== "string") {
179
+ canonErrors.push(`Canon ${filePath}: field asset_type must be a non-empty string (got ${describeValueType(rawAssetType)})`);
180
+ }
181
+ else {
182
+ const trimmed = rawAssetType.trim();
183
+ if (!trimmed) {
184
+ canonErrors.push(`Canon ${filePath}: field asset_type must be non-empty`);
185
+ }
186
+ else if (!VALID_CANON_ASSET_TYPES.includes(trimmed)) {
187
+ canonErrors.push(`Canon ${filePath}: field asset_type has invalid value "${trimmed}" (valid: ${VALID_CANON_ASSET_TYPES_LABEL})`);
188
+ }
189
+ else {
190
+ assetType = trimmed;
191
+ }
192
+ }
142
193
  canonAssets.push({ id, title, summary, assetType, filePath });
143
194
  }
144
195
  catch (error) {
@@ -146,11 +197,15 @@ async function parseCanon(canonRoot, debug) {
146
197
  canonErrors.push(`Canon ${filePath} parse failed: ${message}`);
147
198
  }
148
199
  }
149
- const duplicateCanonIds = duplicateValues(canonAssets.map((asset) => asset.id).filter((id) => Boolean(id)));
200
+ const duplicateCanonIds = duplicateValues(canonAssets.map((asset) => asset.id).filter((cid) => Boolean(cid)));
150
201
  if (duplicateCanonIds.length > 0) {
151
202
  debugLog(debug, "[git-validate] duplicate canon ids", { duplicateCanonIds });
152
203
  for (const duplicateId of duplicateCanonIds) {
153
- canonErrors.push(`Duplicate canon id found: ${duplicateId}`);
204
+ const paths = canonAssets
205
+ .filter((asset) => asset.id === duplicateId)
206
+ .map((asset) => asset.filePath)
207
+ .sort();
208
+ canonErrors.push(`Duplicate canon id "${duplicateId}" in files: ${paths.join(", ")}`);
154
209
  }
155
210
  }
156
211
  return { canonCount: files.length, canonAssets, canonErrors };
@@ -215,7 +270,7 @@ async function validateDeliveryModelAtPath(params) {
215
270
  canonCount: 0,
216
271
  taskErrors,
217
272
  canonErrors,
218
- errors: [`model-spec.yaml parse failed: ${message}`],
273
+ errors: [`model-spec.yaml parse failed at ${modelSpecPath}: ${message}`],
219
274
  warnings,
220
275
  };
221
276
  }
@@ -249,6 +304,28 @@ async function validateDeliveryModelAtPath(params) {
249
304
  ? spec.type
250
305
  : null;
251
306
  const phases = phaseSummary(spec.phases);
307
+ let tasksDirOk = false;
308
+ try {
309
+ const tasksStat = await (0, promises_1.stat)(tasksRoot);
310
+ tasksDirOk = tasksStat.isDirectory();
311
+ if (!tasksDirOk) {
312
+ errors.push(`tasks/ must exist and be a directory: ${tasksRoot}`);
313
+ }
314
+ }
315
+ catch {
316
+ errors.push(`tasks/ must exist and be a directory: ${tasksRoot}`);
317
+ }
318
+ let canonDirOk = false;
319
+ try {
320
+ const canonStat = await (0, promises_1.stat)(canonRoot);
321
+ canonDirOk = canonStat.isDirectory();
322
+ if (!canonDirOk) {
323
+ errors.push(`canon/ must exist and be a directory: ${canonRoot}`);
324
+ }
325
+ }
326
+ catch {
327
+ errors.push(`canon/ must exist and be a directory: ${canonRoot}`);
328
+ }
252
329
  if (!modelId)
253
330
  errors.push("model-spec.yaml is missing required field: id");
254
331
  if (!version)
@@ -268,7 +345,7 @@ async function validateDeliveryModelAtPath(params) {
268
345
  if (duplicatePhaseIds.length > 0) {
269
346
  debugLog(debug, "[git-validate] duplicate phase ids", { duplicatePhaseIds });
270
347
  for (const duplicateId of duplicatePhaseIds) {
271
- errors.push(`Duplicate phase id found: ${duplicateId}`);
348
+ errors.push(`Duplicate phase id "${duplicateId}"`);
272
349
  }
273
350
  }
274
351
  const duplicatePhaseSequences = duplicateValues(phases
@@ -281,23 +358,37 @@ async function validateDeliveryModelAtPath(params) {
281
358
  errors.push(`Duplicate phase sequence found: ${duplicateSequence}`);
282
359
  }
283
360
  }
284
- const { taskCount, tasks, taskErrors: parsedTaskErrors } = await parseTasks(tasksRoot, debug);
285
- const { canonCount, canonAssets, canonErrors: parsedCanonErrors } = await parseCanon(canonRoot, debug);
286
- taskErrors.push(...parsedTaskErrors);
287
- canonErrors.push(...parsedCanonErrors);
288
- const validPhaseIds = new Set(phases.map((phase) => phase.id).filter((id) => Boolean(id)));
361
+ let taskCount = 0;
362
+ let tasks = [];
363
+ if (tasksDirOk) {
364
+ const parsed = await parseTasks(tasksRoot, debug);
365
+ taskCount = parsed.taskCount;
366
+ tasks = parsed.tasks;
367
+ taskErrors.push(...parsed.taskErrors);
368
+ }
369
+ let canonCount = 0;
370
+ let canonAssets = [];
371
+ if (canonDirOk) {
372
+ const parsed = await parseCanon(canonRoot, debug);
373
+ canonCount = parsed.canonCount;
374
+ canonAssets = parsed.canonAssets;
375
+ canonErrors.push(...parsed.canonErrors);
376
+ }
377
+ const validPhaseList = phases.map((phase) => phase.id).filter((id) => Boolean(id));
378
+ const validPhaseIds = new Set(validPhaseList);
379
+ const validPhasesLabel = validPhaseList.length > 0 ? validPhaseList.join(", ") : "(none defined in model-spec.yaml)";
289
380
  const validCanonIds = new Set(canonAssets.map((asset) => asset.id).filter((id) => Boolean(id)));
290
381
  const invalidPhaseRefs = [];
291
382
  const brokenCanonRefs = [];
292
383
  for (const task of tasks) {
293
384
  if (task.phase && !validPhaseIds.has(task.phase)) {
294
- invalidPhaseRefs.push(`${task.id ?? task.filePath} -> ${task.phase}`);
295
- taskErrors.push(`Task ${task.id ?? task.filePath} references unknown phase: ${task.phase}`);
385
+ invalidPhaseRefs.push(`${task.filePath} -> ${task.phase}`);
386
+ taskErrors.push(`Task ${task.filePath}: field phase has invalid value "${task.phase}" (valid phases: ${validPhasesLabel})`);
296
387
  }
297
388
  for (const canonRef of task.canonRefs) {
298
389
  if (!validCanonIds.has(canonRef)) {
299
- brokenCanonRefs.push(`${task.id ?? task.filePath} -> ${canonRef}`);
300
- taskErrors.push(`Task ${task.id ?? task.filePath} references unknown canon id: ${canonRef}`);
390
+ brokenCanonRefs.push(`${task.filePath} -> ${canonRef}`);
391
+ taskErrors.push(`Task ${task.filePath}: field canon references unknown canon id "${canonRef}"`);
301
392
  }
302
393
  }
303
394
  }
@@ -334,7 +425,10 @@ function debugLog(enabled, ...args) {
334
425
  }
335
426
  catch (error) {
336
427
  // If output is being piped and the reader closes early (e.g. `| head`), ignore EPIPE.
337
- if (typeof error === "object" && error && error.code === "EPIPE")
428
+ if (typeof error === "object" &&
429
+ error &&
430
+ "code" in error &&
431
+ error.code === "EPIPE")
338
432
  return;
339
433
  throw error;
340
434
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onboardingiq/delivery-model-validator",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
4
4
  "description": "Local validation CLI for OnboardingIQ delivery model repositories (model-spec, phases, tasks, canon).",
5
5
  "license": "UNLICENSED",
6
6
  "type": "commonjs",
@@ -14,6 +14,7 @@
14
14
  "exports": {
15
15
  ".": {
16
16
  "types": "./dist/index.d.ts",
17
+ "import": "./dist/index.js",
17
18
  "require": "./dist/index.js"
18
19
  }
19
20
  },