@onboardingiq/delivery-model-validator 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +54 -0
- package/dist/formatValidationOutput.d.ts +9 -0
- package/dist/formatValidationOutput.d.ts.map +1 -0
- package/dist/formatValidationOutput.js +47 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/validateDeliveryModel.d.ts +29 -0
- package/dist/validateDeliveryModel.d.ts.map +1 -0
- package/dist/validateDeliveryModel.js +341 -0
- package/package.json +39 -0
package/README.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Delivery Model Validator
|
|
2
|
+
|
|
3
|
+
Local validation CLI for delivery model repositories used by OnboardingIQ.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm i -g @onboardingiq/delivery-model-validator
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
delivery-model-validator "/path/to/delivery-model-root"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Examples:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
delivery-model-validator "/Users/john/.../celeritas-delivery-model"
|
|
21
|
+
delivery-model-validator "C:\\Users\\john\\...\\celeritas-delivery-model"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Exit Codes
|
|
25
|
+
|
|
26
|
+
- `0`: validation passed
|
|
27
|
+
- `1`: validation failed
|
|
28
|
+
- `2`: CLI/runtime error (bad path, unreadable files, exception)
|
|
29
|
+
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
4
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
5
|
+
};
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
8
|
+
const promises_1 = require("node:fs/promises");
|
|
9
|
+
const validateDeliveryModel_1 = require("./validateDeliveryModel");
|
|
10
|
+
const formatValidationOutput_1 = require("./formatValidationOutput");
|
|
11
|
+
function printUsage() {
|
|
12
|
+
console.log("Usage: delivery-model-validator <path-to-delivery-model-root>");
|
|
13
|
+
console.log("Options: --debug");
|
|
14
|
+
}
|
|
15
|
+
async function main() {
|
|
16
|
+
const rawArgs = process.argv.slice(2).filter(Boolean);
|
|
17
|
+
const debugFlag = rawArgs.includes("--debug");
|
|
18
|
+
const debugEnv = process.env.DEBUG === "1";
|
|
19
|
+
const debug = debugFlag || debugEnv;
|
|
20
|
+
const args = rawArgs.filter((arg) => arg !== "--debug");
|
|
21
|
+
const inputPath = args[0];
|
|
22
|
+
if (!inputPath || inputPath === "--help" || inputPath === "-h") {
|
|
23
|
+
printUsage();
|
|
24
|
+
process.exit(inputPath ? 0 : 2);
|
|
25
|
+
}
|
|
26
|
+
const modelRoot = node_path_1.default.resolve(inputPath);
|
|
27
|
+
try {
|
|
28
|
+
const info = await (0, promises_1.stat)(modelRoot);
|
|
29
|
+
if (!info.isDirectory()) {
|
|
30
|
+
console.error(`Path is not a directory: ${modelRoot}`);
|
|
31
|
+
process.exit(2);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
const message = error instanceof Error ? error.message : "Unable to read path";
|
|
36
|
+
console.error(`Unable to access path: ${modelRoot}`);
|
|
37
|
+
console.error(message);
|
|
38
|
+
process.exit(2);
|
|
39
|
+
}
|
|
40
|
+
const result = await (0, validateDeliveryModel_1.validateDeliveryModelAtPath)({
|
|
41
|
+
modelRoot,
|
|
42
|
+
repo: modelRoot,
|
|
43
|
+
branch: "local",
|
|
44
|
+
debug,
|
|
45
|
+
});
|
|
46
|
+
const formatted = (0, formatValidationOutput_1.formatValidationOutput)(result);
|
|
47
|
+
process.stdout.write(formatted.text + "\n");
|
|
48
|
+
process.exit(formatted.exitCode);
|
|
49
|
+
}
|
|
50
|
+
main().catch((error) => {
|
|
51
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
52
|
+
console.error(message);
|
|
53
|
+
process.exit(2);
|
|
54
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { DeliveryModelValidationResponse } from "./validateDeliveryModel";
|
|
2
|
+
export type FormattedValidationOutput = {
|
|
3
|
+
exitCode: 0 | 1;
|
|
4
|
+
text: string;
|
|
5
|
+
};
|
|
6
|
+
export declare function formatValidationOutput(result: DeliveryModelValidationResponse, opts?: {
|
|
7
|
+
showResolvedPath?: boolean;
|
|
8
|
+
}): FormattedValidationOutput;
|
|
9
|
+
//# sourceMappingURL=formatValidationOutput.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"formatValidationOutput.d.ts","sourceRoot":"","sources":["../src/formatValidationOutput.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,+BAA+B,EAAE,MAAM,yBAAyB,CAAC;AAE/E,MAAM,MAAM,yBAAyB,GAAG;IACtC,QAAQ,EAAE,CAAC,GAAG,CAAC,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;CACd,CAAC;AAOF,wBAAgB,sBAAsB,CACpC,MAAM,EAAE,+BAA+B,EACvC,IAAI,CAAC,EAAE;IAAE,gBAAgB,CAAC,EAAE,OAAO,CAAA;CAAE,GACpC,yBAAyB,CA6C3B"}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.formatValidationOutput = formatValidationOutput;
|
|
7
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
8
|
+
function line(status, label) {
|
|
9
|
+
const tag = status === "ok" ? "[OK]" : "[FAIL]";
|
|
10
|
+
return `${tag} ${label}`;
|
|
11
|
+
}
|
|
12
|
+
function formatValidationOutput(result, opts) {
|
|
13
|
+
const lines = [];
|
|
14
|
+
lines.push("");
|
|
15
|
+
lines.push(`Delivery model: ${result.modelId ?? "unknown"}`);
|
|
16
|
+
lines.push(`Version: ${result.version ?? "unknown"}`);
|
|
17
|
+
if (opts?.showResolvedPath) {
|
|
18
|
+
lines.push(`Path: ${node_path_1.default.resolve(result.modelPath)}`);
|
|
19
|
+
}
|
|
20
|
+
lines.push("");
|
|
21
|
+
const modelSpecOk = !result.errors.some((e) => e.includes("model-spec.yaml not found") || e.includes("model-spec.yaml parse failed"));
|
|
22
|
+
lines.push(line(modelSpecOk ? "ok" : "fail", "model-spec.yaml"));
|
|
23
|
+
const phasesOk = result.phases.length > 0
|
|
24
|
+
&& !result.errors.some((e) => e.includes("Duplicate phase") || e.includes("phases["));
|
|
25
|
+
lines.push(line(phasesOk ? "ok" : "fail", `phases (${result.phases.length})`));
|
|
26
|
+
lines.push(line(result.taskErrors.length === 0 ? "ok" : "fail", `tasks (${result.taskCount})`));
|
|
27
|
+
lines.push(line(result.canonErrors.length === 0 ? "ok" : "fail", `canon (${result.canonCount})`));
|
|
28
|
+
if (result.warnings.length) {
|
|
29
|
+
lines.push("");
|
|
30
|
+
lines.push("Warnings:");
|
|
31
|
+
for (const w of result.warnings)
|
|
32
|
+
lines.push(`- ${w}`);
|
|
33
|
+
}
|
|
34
|
+
if (result.errors.length) {
|
|
35
|
+
lines.push("");
|
|
36
|
+
lines.push("Errors:");
|
|
37
|
+
for (const e of result.errors)
|
|
38
|
+
lines.push(`- ${e}`);
|
|
39
|
+
}
|
|
40
|
+
lines.push("");
|
|
41
|
+
if (result.status === "ok" && result.errors.length === 0) {
|
|
42
|
+
lines.push("Validation passed.");
|
|
43
|
+
return { exitCode: 0, text: lines.join("\n") };
|
|
44
|
+
}
|
|
45
|
+
lines.push("Validation failed.");
|
|
46
|
+
return { exitCode: 1, text: lines.join("\n") };
|
|
47
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,2BAA2B,EAAE,MAAM,yBAAyB,CAAC;AACtE,YAAY,EAAE,+BAA+B,EAAE,MAAM,yBAAyB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.validateDeliveryModelAtPath = void 0;
|
|
4
|
+
var validateDeliveryModel_1 = require("./validateDeliveryModel");
|
|
5
|
+
Object.defineProperty(exports, "validateDeliveryModelAtPath", { enumerable: true, get: function () { return validateDeliveryModel_1.validateDeliveryModelAtPath; } });
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
type ParsedPhase = {
|
|
2
|
+
id: string | null;
|
|
3
|
+
sequence: number | null;
|
|
4
|
+
};
|
|
5
|
+
export type DeliveryModelValidationResponse = {
|
|
6
|
+
status: "ok" | "error";
|
|
7
|
+
repo: string;
|
|
8
|
+
branch: string;
|
|
9
|
+
modelPath: string;
|
|
10
|
+
modelId: string | null;
|
|
11
|
+
version: string | null;
|
|
12
|
+
modelType: string | null;
|
|
13
|
+
phases: ParsedPhase[];
|
|
14
|
+
taskCount: number;
|
|
15
|
+
canonCount: number;
|
|
16
|
+
taskErrors: string[];
|
|
17
|
+
canonErrors: string[];
|
|
18
|
+
errors: string[];
|
|
19
|
+
warnings: string[];
|
|
20
|
+
};
|
|
21
|
+
export declare function validateDeliveryModelAtPath(params: {
|
|
22
|
+
modelRoot: string;
|
|
23
|
+
repo: string;
|
|
24
|
+
branch: string;
|
|
25
|
+
warnings?: string[];
|
|
26
|
+
debug?: boolean;
|
|
27
|
+
}): Promise<DeliveryModelValidationResponse>;
|
|
28
|
+
export {};
|
|
29
|
+
//# sourceMappingURL=validateDeliveryModel.d.ts.map
|
|
@@ -0,0 +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"}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.validateDeliveryModelAtPath = validateDeliveryModelAtPath;
|
|
7
|
+
const promises_1 = require("node:fs/promises");
|
|
8
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
9
|
+
const js_yaml_1 = __importDefault(require("js-yaml"));
|
|
10
|
+
const gray_matter_1 = __importDefault(require("gray-matter"));
|
|
11
|
+
function phaseSummary(phases) {
|
|
12
|
+
if (!Array.isArray(phases))
|
|
13
|
+
return [];
|
|
14
|
+
return phases.map((entry) => {
|
|
15
|
+
if (!entry || typeof entry !== "object") {
|
|
16
|
+
return { id: null, sequence: null };
|
|
17
|
+
}
|
|
18
|
+
const row = entry;
|
|
19
|
+
return {
|
|
20
|
+
id: typeof row.id === "string" && row.id.trim() ? row.id.trim() : null,
|
|
21
|
+
sequence: typeof row.sequence === "number" ? row.sequence : null,
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
function asNonEmptyString(value) {
|
|
26
|
+
if (typeof value !== "string")
|
|
27
|
+
return null;
|
|
28
|
+
const trimmed = value.trim();
|
|
29
|
+
return trimmed ? trimmed : null;
|
|
30
|
+
}
|
|
31
|
+
function duplicateValues(values) {
|
|
32
|
+
const counts = new Map();
|
|
33
|
+
for (const value of values) {
|
|
34
|
+
counts.set(value, (counts.get(value) ?? 0) + 1);
|
|
35
|
+
}
|
|
36
|
+
return Array.from(counts.entries())
|
|
37
|
+
.filter(([, count]) => count > 1)
|
|
38
|
+
.map(([value]) => value);
|
|
39
|
+
}
|
|
40
|
+
async function walkFiles(root) {
|
|
41
|
+
const entries = await (0, promises_1.readdir)(root, { withFileTypes: true });
|
|
42
|
+
const files = [];
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
const fullPath = node_path_1.default.join(root, entry.name);
|
|
45
|
+
if (entry.isDirectory()) {
|
|
46
|
+
files.push(...(await walkFiles(fullPath)));
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (entry.isFile()) {
|
|
50
|
+
files.push(fullPath);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return files;
|
|
54
|
+
}
|
|
55
|
+
async function parseTasks(tasksRoot, debug) {
|
|
56
|
+
const taskErrors = [];
|
|
57
|
+
const tasks = [];
|
|
58
|
+
let files = [];
|
|
59
|
+
try {
|
|
60
|
+
files = (await walkFiles(tasksRoot)).filter((file) => file.toLowerCase().endsWith(".md"));
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
taskErrors.push(`Tasks directory not found: ${tasksRoot}`);
|
|
64
|
+
return { taskCount: 0, tasks, taskErrors };
|
|
65
|
+
}
|
|
66
|
+
debugLog(debug, "[git-validate] tasks discovered", { taskCount: files.length, tasksRoot });
|
|
67
|
+
for (const filePath of files) {
|
|
68
|
+
try {
|
|
69
|
+
const raw = await (0, promises_1.readFile)(filePath, "utf8");
|
|
70
|
+
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);
|
|
74
|
+
const canonRefs = [];
|
|
75
|
+
const canonField = frontmatter.canon;
|
|
76
|
+
if (canonField !== undefined) {
|
|
77
|
+
if (!Array.isArray(canonField)) {
|
|
78
|
+
taskErrors.push(`Task ${filePath} has invalid canon field: expected array`);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
for (const item of canonField) {
|
|
82
|
+
const ref = asNonEmptyString(item);
|
|
83
|
+
if (!ref) {
|
|
84
|
+
taskErrors.push(`Task ${filePath} has invalid canon entry: expected non-empty string`);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
canonRefs.push(ref);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
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
|
+
tasks.push({ id, title, phase, canonRefs, filePath });
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
const message = error instanceof Error ? error.message : "Unknown parse error";
|
|
102
|
+
taskErrors.push(`Task ${filePath} parse failed: ${message}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const duplicateTaskIds = duplicateValues(tasks.map((task) => task.id).filter((id) => Boolean(id)));
|
|
106
|
+
if (duplicateTaskIds.length > 0) {
|
|
107
|
+
debugLog(debug, "[git-validate] duplicate task ids", { duplicateTaskIds });
|
|
108
|
+
for (const duplicateId of duplicateTaskIds) {
|
|
109
|
+
taskErrors.push(`Duplicate task id found: ${duplicateId}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { taskCount: files.length, tasks, taskErrors };
|
|
113
|
+
}
|
|
114
|
+
async function parseCanon(canonRoot, debug) {
|
|
115
|
+
const canonErrors = [];
|
|
116
|
+
const canonAssets = [];
|
|
117
|
+
let files = [];
|
|
118
|
+
try {
|
|
119
|
+
files = await walkFiles(canonRoot);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
canonErrors.push(`Canon directory not found: ${canonRoot}`);
|
|
123
|
+
return { canonCount: 0, canonAssets, canonErrors };
|
|
124
|
+
}
|
|
125
|
+
debugLog(debug, "[git-validate] canon discovered", { canonCount: files.length, canonRoot });
|
|
126
|
+
for (const filePath of files) {
|
|
127
|
+
try {
|
|
128
|
+
const raw = await (0, promises_1.readFile)(filePath, "utf8");
|
|
129
|
+
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`);
|
|
142
|
+
canonAssets.push({ id, title, summary, assetType, filePath });
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
const message = error instanceof Error ? error.message : "Unknown parse error";
|
|
146
|
+
canonErrors.push(`Canon ${filePath} parse failed: ${message}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const duplicateCanonIds = duplicateValues(canonAssets.map((asset) => asset.id).filter((id) => Boolean(id)));
|
|
150
|
+
if (duplicateCanonIds.length > 0) {
|
|
151
|
+
debugLog(debug, "[git-validate] duplicate canon ids", { duplicateCanonIds });
|
|
152
|
+
for (const duplicateId of duplicateCanonIds) {
|
|
153
|
+
canonErrors.push(`Duplicate canon id found: ${duplicateId}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { canonCount: files.length, canonAssets, canonErrors };
|
|
157
|
+
}
|
|
158
|
+
async function validateDeliveryModelAtPath(params) {
|
|
159
|
+
const errors = [];
|
|
160
|
+
const warnings = [...(params.warnings ?? [])];
|
|
161
|
+
const taskErrors = [];
|
|
162
|
+
const canonErrors = [];
|
|
163
|
+
const debug = Boolean(params.debug);
|
|
164
|
+
const modelRoot = params.modelRoot;
|
|
165
|
+
const modelSpecPath = node_path_1.default.join(modelRoot, "model-spec.yaml");
|
|
166
|
+
const tasksRoot = node_path_1.default.join(modelRoot, "tasks");
|
|
167
|
+
const canonRoot = node_path_1.default.join(modelRoot, "canon");
|
|
168
|
+
debugLog(debug, "[git-validate] resolved paths", { modelRoot, tasksRoot, canonRoot });
|
|
169
|
+
let modelSpecRaw = "";
|
|
170
|
+
try {
|
|
171
|
+
modelSpecRaw = await (0, promises_1.readFile)(modelSpecPath, "utf8");
|
|
172
|
+
debugLog(debug, "[git-validate] model-spec found", { modelSpecPath });
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
debugLog(debug, "[git-validate] model-spec not found", { modelSpecPath });
|
|
176
|
+
return {
|
|
177
|
+
status: "error",
|
|
178
|
+
repo: params.repo,
|
|
179
|
+
branch: params.branch,
|
|
180
|
+
modelPath: modelRoot,
|
|
181
|
+
modelId: null,
|
|
182
|
+
version: null,
|
|
183
|
+
modelType: null,
|
|
184
|
+
phases: [],
|
|
185
|
+
taskCount: 0,
|
|
186
|
+
canonCount: 0,
|
|
187
|
+
taskErrors,
|
|
188
|
+
canonErrors,
|
|
189
|
+
errors: [`model-spec.yaml not found at ${modelSpecPath}`],
|
|
190
|
+
warnings,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
let spec = null;
|
|
194
|
+
try {
|
|
195
|
+
const parsed = js_yaml_1.default.load(modelSpecRaw);
|
|
196
|
+
if (!parsed || typeof parsed !== "object") {
|
|
197
|
+
throw new Error("YAML root must be an object");
|
|
198
|
+
}
|
|
199
|
+
spec = parsed;
|
|
200
|
+
debugLog(debug, "[git-validate] parse success", { modelSpecPath });
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
const message = error instanceof Error ? error.message : "Unknown YAML parse error";
|
|
204
|
+
debugLog(debug, "[git-validate] parse failure", { modelSpecPath, error: message });
|
|
205
|
+
return {
|
|
206
|
+
status: "error",
|
|
207
|
+
repo: params.repo,
|
|
208
|
+
branch: params.branch,
|
|
209
|
+
modelPath: modelRoot,
|
|
210
|
+
modelId: null,
|
|
211
|
+
version: null,
|
|
212
|
+
modelType: null,
|
|
213
|
+
phases: [],
|
|
214
|
+
taskCount: 0,
|
|
215
|
+
canonCount: 0,
|
|
216
|
+
taskErrors,
|
|
217
|
+
canonErrors,
|
|
218
|
+
errors: [`model-spec.yaml parse failed: ${message}`],
|
|
219
|
+
warnings,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
if (!spec) {
|
|
223
|
+
return {
|
|
224
|
+
status: "error",
|
|
225
|
+
repo: params.repo,
|
|
226
|
+
branch: params.branch,
|
|
227
|
+
modelPath: modelRoot,
|
|
228
|
+
modelId: null,
|
|
229
|
+
version: null,
|
|
230
|
+
modelType: null,
|
|
231
|
+
phases: [],
|
|
232
|
+
taskCount: 0,
|
|
233
|
+
canonCount: 0,
|
|
234
|
+
taskErrors,
|
|
235
|
+
canonErrors,
|
|
236
|
+
errors: ["model-spec.yaml parse failed: parser returned no data"],
|
|
237
|
+
warnings,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
const modelId = typeof spec.id === "string" && spec.id.trim() ? spec.id.trim() : null;
|
|
241
|
+
const version = typeof spec.version === "string"
|
|
242
|
+
? spec.version
|
|
243
|
+
: typeof spec.version === "number"
|
|
244
|
+
? String(spec.version)
|
|
245
|
+
: null;
|
|
246
|
+
const modelType = typeof spec.model_type === "string"
|
|
247
|
+
? spec.model_type
|
|
248
|
+
: typeof spec.type === "string"
|
|
249
|
+
? spec.type
|
|
250
|
+
: null;
|
|
251
|
+
const phases = phaseSummary(spec.phases);
|
|
252
|
+
if (!modelId)
|
|
253
|
+
errors.push("model-spec.yaml is missing required field: id");
|
|
254
|
+
if (!version)
|
|
255
|
+
errors.push("model-spec.yaml is missing required field: version");
|
|
256
|
+
if (!Array.isArray(spec.phases) || spec.phases.length === 0) {
|
|
257
|
+
errors.push("model-spec.yaml is missing required field: phases (non-empty list)");
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
phases.forEach((phase, index) => {
|
|
261
|
+
if (!phase.id)
|
|
262
|
+
errors.push(`phases[${index}] is missing required field: id`);
|
|
263
|
+
if (phase.sequence === null)
|
|
264
|
+
errors.push(`phases[${index}] is missing required field: sequence`);
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
const duplicatePhaseIds = duplicateValues(phases.map((phase) => phase.id).filter((id) => Boolean(id)));
|
|
268
|
+
if (duplicatePhaseIds.length > 0) {
|
|
269
|
+
debugLog(debug, "[git-validate] duplicate phase ids", { duplicatePhaseIds });
|
|
270
|
+
for (const duplicateId of duplicatePhaseIds) {
|
|
271
|
+
errors.push(`Duplicate phase id found: ${duplicateId}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const duplicatePhaseSequences = duplicateValues(phases
|
|
275
|
+
.map((phase) => phase.sequence)
|
|
276
|
+
.filter((value) => value !== null)
|
|
277
|
+
.map((value) => String(value)));
|
|
278
|
+
if (duplicatePhaseSequences.length > 0) {
|
|
279
|
+
debugLog(debug, "[git-validate] duplicate phase sequences", { duplicatePhaseSequences });
|
|
280
|
+
for (const duplicateSequence of duplicatePhaseSequences) {
|
|
281
|
+
errors.push(`Duplicate phase sequence found: ${duplicateSequence}`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
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)));
|
|
289
|
+
const validCanonIds = new Set(canonAssets.map((asset) => asset.id).filter((id) => Boolean(id)));
|
|
290
|
+
const invalidPhaseRefs = [];
|
|
291
|
+
const brokenCanonRefs = [];
|
|
292
|
+
for (const task of tasks) {
|
|
293
|
+
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}`);
|
|
296
|
+
}
|
|
297
|
+
for (const canonRef of task.canonRefs) {
|
|
298
|
+
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}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (invalidPhaseRefs.length > 0) {
|
|
305
|
+
debugLog(debug, "[git-validate] invalid phase references", { invalidPhaseRefs });
|
|
306
|
+
}
|
|
307
|
+
if (brokenCanonRefs.length > 0) {
|
|
308
|
+
debugLog(debug, "[git-validate] broken canon references", { brokenCanonRefs });
|
|
309
|
+
}
|
|
310
|
+
errors.push(...taskErrors, ...canonErrors);
|
|
311
|
+
return {
|
|
312
|
+
status: errors.length ? "error" : "ok",
|
|
313
|
+
repo: params.repo,
|
|
314
|
+
branch: params.branch,
|
|
315
|
+
modelPath: modelRoot,
|
|
316
|
+
modelId,
|
|
317
|
+
version,
|
|
318
|
+
modelType,
|
|
319
|
+
phases,
|
|
320
|
+
taskCount,
|
|
321
|
+
canonCount,
|
|
322
|
+
taskErrors,
|
|
323
|
+
canonErrors,
|
|
324
|
+
errors,
|
|
325
|
+
warnings,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
function debugLog(enabled, ...args) {
|
|
329
|
+
if (!enabled)
|
|
330
|
+
return;
|
|
331
|
+
// Keep the existing logging shape/prefixes; only gate output.
|
|
332
|
+
try {
|
|
333
|
+
console.error(...args);
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
// If output is being piped and the reader closes early (e.g. `| head`), ignore EPIPE.
|
|
337
|
+
if (typeof error === "object" && error && error.code === "EPIPE")
|
|
338
|
+
return;
|
|
339
|
+
throw error;
|
|
340
|
+
}
|
|
341
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@onboardingiq/delivery-model-validator",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Local validation CLI for OnboardingIQ delivery model repositories (model-spec, phases, tasks, canon).",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"type": "commonjs",
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"files": [
|
|
10
|
+
"dist/",
|
|
11
|
+
"README.md",
|
|
12
|
+
"package.json"
|
|
13
|
+
],
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"require": "./dist/index.js"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"bin": {
|
|
21
|
+
"delivery-model-validator": "dist/cli.js"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsc -p tsconfig.json && node scripts/postbuild.mjs",
|
|
25
|
+
"prepack": "npm run build"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"gray-matter": "^4.0.3",
|
|
29
|
+
"js-yaml": "^4.1.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/js-yaml": "^4.0.9",
|
|
33
|
+
"@types/node": "^20",
|
|
34
|
+
"typescript": "^5.9.3"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18"
|
|
38
|
+
}
|
|
39
|
+
}
|