@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":"
|
|
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
|
-
|
|
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 =
|
|
72
|
-
const title =
|
|
73
|
-
const 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}
|
|
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
|
-
|
|
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((
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
131
|
-
const title =
|
|
132
|
-
const summary =
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (
|
|
139
|
-
canonErrors.push(`Canon ${filePath}
|
|
140
|
-
|
|
141
|
-
|
|
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((
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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.
|
|
295
|
-
taskErrors.push(`Task ${task.
|
|
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.
|
|
300
|
-
taskErrors.push(`Task ${task.
|
|
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" &&
|
|
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
|
|
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
|
},
|