yaml-validate-cli 1.0.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.
- package/cli.js +311 -0
- package/index.js +73 -0
- package/package.json +12 -0
package/cli.js
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
// ANSI color codes
|
|
8
|
+
const RED = '\x1b[31m';
|
|
9
|
+
const GREEN = '\x1b[32m';
|
|
10
|
+
const YELLOW = '\x1b[33m';
|
|
11
|
+
const CYAN = '\x1b[36m';
|
|
12
|
+
const BOLD = '\x1b[1m';
|
|
13
|
+
const RESET = '\x1b[0m';
|
|
14
|
+
const DIM = '\x1b[2m';
|
|
15
|
+
|
|
16
|
+
function colorize(text, ...codes) {
|
|
17
|
+
if (!process.stdout.isTTY) return text;
|
|
18
|
+
return codes.join('') + text + RESET;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function printHelp() {
|
|
22
|
+
console.log(`
|
|
23
|
+
${colorize('yaml-validate', BOLD, CYAN)} - Validate YAML files from the command line
|
|
24
|
+
|
|
25
|
+
${colorize('Usage:', BOLD)}
|
|
26
|
+
yaml-validate <file.yml> [file2.yml ...]
|
|
27
|
+
yaml-validate *.yml
|
|
28
|
+
yaml-validate --strict <files>
|
|
29
|
+
yaml-validate --help
|
|
30
|
+
|
|
31
|
+
${colorize('Options:', BOLD)}
|
|
32
|
+
--strict Treat warnings as errors (exit code 1 if any warnings)
|
|
33
|
+
--no-color Disable colored output
|
|
34
|
+
--quiet Only show errors, suppress pass messages
|
|
35
|
+
--help, -h Show this help message
|
|
36
|
+
--version Show version number
|
|
37
|
+
|
|
38
|
+
${colorize('Examples:', BOLD)}
|
|
39
|
+
yaml-validate config.yml
|
|
40
|
+
yaml-validate docker-compose.yml k8s/*.yml
|
|
41
|
+
yaml-validate --strict .github/workflows/*.yml
|
|
42
|
+
|
|
43
|
+
${colorize('Exit codes:', BOLD)}
|
|
44
|
+
0 All files valid
|
|
45
|
+
1 One or more files have errors (or warnings with --strict)
|
|
46
|
+
2 No files specified or files not found
|
|
47
|
+
`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Core YAML validator — no external dependencies
|
|
51
|
+
function validateYAML(content, filename, strict) {
|
|
52
|
+
const lines = content.split('\n');
|
|
53
|
+
const errors = [];
|
|
54
|
+
const warnings = [];
|
|
55
|
+
|
|
56
|
+
// Track state
|
|
57
|
+
const keyTracker = [{}]; // stack of key maps per indentation level
|
|
58
|
+
let inMultilineScalar = false;
|
|
59
|
+
let multilineIndent = -1;
|
|
60
|
+
let inBlockScalar = false;
|
|
61
|
+
let blockScalarIndent = -1;
|
|
62
|
+
let prevIndent = 0;
|
|
63
|
+
let indentUnit = null; // detected indent size (spaces per level)
|
|
64
|
+
|
|
65
|
+
// Check for BOM
|
|
66
|
+
if (content.charCodeAt(0) === 0xFEFF) {
|
|
67
|
+
warnings.push({ line: 1, msg: 'File starts with BOM (byte order mark) — consider removing it' });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Check for tabs used as indentation
|
|
71
|
+
for (let i = 0; i < lines.length; i++) {
|
|
72
|
+
const lineNum = i + 1;
|
|
73
|
+
const raw = lines[i];
|
|
74
|
+
|
|
75
|
+
// Skip empty lines and comments for most checks
|
|
76
|
+
const trimmed = raw.trimEnd();
|
|
77
|
+
const isEmptyOrComment = trimmed.trim() === '' || trimmed.trim().startsWith('#');
|
|
78
|
+
|
|
79
|
+
// Tab indentation check
|
|
80
|
+
if (/^\t/.test(raw)) {
|
|
81
|
+
errors.push({ line: lineNum, msg: 'Tabs are not allowed as indentation in YAML (use spaces)' });
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Detect indent unit from first indented line
|
|
86
|
+
if (!isEmptyOrComment) {
|
|
87
|
+
const leadingSpaces = raw.match(/^( +)/);
|
|
88
|
+
if (leadingSpaces && indentUnit === null) {
|
|
89
|
+
indentUnit = leadingSpaces[1].length;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Block scalar tracking (| and >)
|
|
94
|
+
if (!isEmptyOrComment) {
|
|
95
|
+
const indent = raw.search(/\S/);
|
|
96
|
+
if (inBlockScalar) {
|
|
97
|
+
if (indent <= blockScalarIndent && trimmed.trim() !== '') {
|
|
98
|
+
inBlockScalar = false;
|
|
99
|
+
} else {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (/:\s*[|>][-+]?\s*$/.test(trimmed)) {
|
|
104
|
+
inBlockScalar = true;
|
|
105
|
+
blockScalarIndent = indent;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (isEmptyOrComment) continue;
|
|
110
|
+
|
|
111
|
+
const indent = raw.search(/\S/);
|
|
112
|
+
const content_part = raw.trim();
|
|
113
|
+
|
|
114
|
+
// Detect keys and values
|
|
115
|
+
const keyMatch = content_part.match(/^([^:'"{\[]+?):\s*(.*)?$/);
|
|
116
|
+
const listItemMatch = content_part.match(/^-\s+(.*)?$/);
|
|
117
|
+
const docStart = content_part === '---';
|
|
118
|
+
const docEnd = content_part === '...';
|
|
119
|
+
|
|
120
|
+
if (docStart || docEnd) {
|
|
121
|
+
// Reset key tracker on new document
|
|
122
|
+
if (docStart) {
|
|
123
|
+
keyTracker.length = 0;
|
|
124
|
+
keyTracker.push({});
|
|
125
|
+
}
|
|
126
|
+
prevIndent = 0;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Check indent consistency (must be multiple of detected unit)
|
|
131
|
+
if (indentUnit && indent > 0 && indent % indentUnit !== 0) {
|
|
132
|
+
warnings.push({ line: lineNum, msg: `Inconsistent indentation: ${indent} spaces (expected multiple of ${indentUnit})` });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Check for trailing spaces
|
|
136
|
+
if (/\s+$/.test(raw) && raw !== '') {
|
|
137
|
+
warnings.push({ line: lineNum, msg: 'Trailing whitespace detected' });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Check for duplicate keys at the same indentation level
|
|
141
|
+
if (keyMatch) {
|
|
142
|
+
const keyName = keyMatch[1].trim();
|
|
143
|
+
|
|
144
|
+
// Normalize indent level index
|
|
145
|
+
const level = indentUnit ? Math.round(indent / indentUnit) : indent;
|
|
146
|
+
while (keyTracker.length <= level) keyTracker.push({});
|
|
147
|
+
// Reset deeper levels when we go back up
|
|
148
|
+
if (indent < prevIndent) {
|
|
149
|
+
for (let l = level + 1; l < keyTracker.length; l++) {
|
|
150
|
+
keyTracker[l] = {};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (keyTracker[level][keyName]) {
|
|
155
|
+
errors.push({ line: lineNum, msg: `Duplicate key "${keyName}" (first seen at line ${keyTracker[level][keyName]})` });
|
|
156
|
+
} else {
|
|
157
|
+
keyTracker[level][keyName] = lineNum;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
prevIndent = indent;
|
|
161
|
+
|
|
162
|
+
// Check for unquoted special chars in values
|
|
163
|
+
const value = (keyMatch[2] || '').trim();
|
|
164
|
+
if (value && !value.startsWith('"') && !value.startsWith("'") && !value.startsWith('|') && !value.startsWith('>')) {
|
|
165
|
+
// Warn on values that look like they might be ambiguous booleans
|
|
166
|
+
if (/^(yes|no|on|off|true|false)$/i.test(value)) {
|
|
167
|
+
warnings.push({ line: lineNum, msg: `Ambiguous boolean-like value "${value}" for key "${keyName}" — consider quoting if string intended` });
|
|
168
|
+
}
|
|
169
|
+
// Warn on numeric-looking values with leading zeros (could be octal)
|
|
170
|
+
if (/^0\d+$/.test(value)) {
|
|
171
|
+
warnings.push({ line: lineNum, msg: `Value "${value}" has leading zero — may be interpreted as octal` });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Check for unclosed brackets/braces inline
|
|
177
|
+
const inlineContent = content_part.replace(/"[^"]*"|'[^']*'/g, '""'); // strip quoted strings
|
|
178
|
+
const openBrackets = (inlineContent.match(/\[/g) || []).length;
|
|
179
|
+
const closeBrackets = (inlineContent.match(/\]/g) || []).length;
|
|
180
|
+
const openBraces = (inlineContent.match(/\{/g) || []).length;
|
|
181
|
+
const closeBraces = (inlineContent.match(/\}/g) || []).length;
|
|
182
|
+
if (openBrackets !== closeBrackets) {
|
|
183
|
+
errors.push({ line: lineNum, msg: `Unmatched brackets [ ] on this line (${openBrackets} open, ${closeBrackets} close)` });
|
|
184
|
+
}
|
|
185
|
+
if (openBraces !== closeBraces) {
|
|
186
|
+
errors.push({ line: lineNum, msg: `Unmatched braces { } on this line (${openBraces} open, ${closeBraces} close)` });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Check for colon in unquoted string values without space after
|
|
190
|
+
if (/:\S/.test(content_part) && !content_part.startsWith('http') && !keyMatch) {
|
|
191
|
+
// Might be a mapping indicator without space
|
|
192
|
+
const colonNoSpace = content_part.match(/([^"':]):([\S])/);
|
|
193
|
+
if (colonNoSpace) {
|
|
194
|
+
warnings.push({ line: lineNum, msg: `Colon followed by non-space character — may cause parse errors if intended as mapping` });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return { errors, warnings };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function validateFile(filePath, options) {
|
|
203
|
+
const { strict, quiet } = options;
|
|
204
|
+
|
|
205
|
+
if (!fs.existsSync(filePath)) {
|
|
206
|
+
console.log(colorize(` NOT FOUND: ${filePath}`, RED));
|
|
207
|
+
return { valid: false, notFound: true };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let content;
|
|
211
|
+
try {
|
|
212
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
213
|
+
} catch (e) {
|
|
214
|
+
console.log(colorize(` ERROR reading ${filePath}: ${e.message}`, RED));
|
|
215
|
+
return { valid: false };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const { errors, warnings } = validateYAML(content, filePath, strict);
|
|
219
|
+
const hasErrors = errors.length > 0;
|
|
220
|
+
const hasWarnings = warnings.length > 0;
|
|
221
|
+
const failed = hasErrors || (strict && hasWarnings);
|
|
222
|
+
const shortName = path.relative(process.cwd(), filePath);
|
|
223
|
+
|
|
224
|
+
if (failed) {
|
|
225
|
+
console.log(colorize(`${BOLD}FAIL${RESET} ${colorize(shortName, BOLD)}`, RED));
|
|
226
|
+
for (const e of errors) {
|
|
227
|
+
console.log(colorize(` [ERROR]`, RED) + colorize(` line ${e.line}: `, DIM) + e.msg);
|
|
228
|
+
}
|
|
229
|
+
if (strict) {
|
|
230
|
+
for (const w of warnings) {
|
|
231
|
+
console.log(colorize(` [WARN] `, YELLOW) + colorize(` line ${w.line}: `, DIM) + w.msg);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
} else {
|
|
235
|
+
if (!quiet) {
|
|
236
|
+
console.log(colorize(`PASS`, GREEN) + ` ${colorize(shortName, BOLD)}` +
|
|
237
|
+
(hasWarnings ? colorize(` (${warnings.length} warning${warnings.length > 1 ? 's' : ''})`, YELLOW) : ''));
|
|
238
|
+
}
|
|
239
|
+
if (hasWarnings && !quiet) {
|
|
240
|
+
for (const w of warnings) {
|
|
241
|
+
console.log(colorize(` [WARN] `, YELLOW) + colorize(` line ${w.line}: `, DIM) + w.msg);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return { valid: !failed, errors: errors.length, warnings: warnings.length };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Expand glob patterns (basic support for *.yml etc.)
|
|
250
|
+
function expandGlob(pattern) {
|
|
251
|
+
const dir = path.dirname(pattern);
|
|
252
|
+
const base = path.basename(pattern);
|
|
253
|
+
if (!base.includes('*') && !base.includes('?')) return [pattern];
|
|
254
|
+
try {
|
|
255
|
+
const entries = fs.readdirSync(dir === '.' ? process.cwd() : dir);
|
|
256
|
+
const regex = new RegExp('^' + base.replace(/\./g, '\\.').replace(/\*/g, '.*').replace(/\?/g, '.') + '$');
|
|
257
|
+
return entries
|
|
258
|
+
.filter(e => regex.test(e))
|
|
259
|
+
.map(e => path.join(dir === '.' ? process.cwd() : dir, e));
|
|
260
|
+
} catch {
|
|
261
|
+
return [];
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Main entry
|
|
266
|
+
function main() {
|
|
267
|
+
const args = process.argv.slice(2);
|
|
268
|
+
|
|
269
|
+
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
|
|
270
|
+
printHelp();
|
|
271
|
+
process.exit(args.length === 0 ? 2 : 0);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (args.includes('--version')) {
|
|
275
|
+
const pkg = require('./package.json');
|
|
276
|
+
console.log(pkg.version);
|
|
277
|
+
process.exit(0);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const strict = args.includes('--strict');
|
|
281
|
+
const quiet = args.includes('--quiet');
|
|
282
|
+
const noColor = args.includes('--no-color');
|
|
283
|
+
if (noColor) process.env.NO_COLOR = '1';
|
|
284
|
+
|
|
285
|
+
const files = args
|
|
286
|
+
.filter(a => !a.startsWith('--'))
|
|
287
|
+
.flatMap(expandGlob)
|
|
288
|
+
.filter(f => f.endsWith('.yml') || f.endsWith('.yaml') || true);
|
|
289
|
+
|
|
290
|
+
if (files.length === 0) {
|
|
291
|
+
console.error(colorize('No files specified or matched.', RED));
|
|
292
|
+
process.exit(2);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
let totalPass = 0, totalFail = 0, totalWarn = 0;
|
|
296
|
+
|
|
297
|
+
console.log(colorize(`\nyaml-validate`, BOLD, CYAN) + colorize(` v1.0.0`, DIM) + (strict ? colorize(' [strict]', YELLOW) : '') + '\n');
|
|
298
|
+
|
|
299
|
+
for (const f of files) {
|
|
300
|
+
const result = validateFile(f, { strict, quiet });
|
|
301
|
+
if (result.valid) totalPass++;
|
|
302
|
+
else totalFail++;
|
|
303
|
+
totalWarn += result.warnings || 0;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
console.log(`\n${colorize('Summary:', BOLD)} ${colorize(`${totalPass} passed`, GREEN)}, ${colorize(`${totalFail} failed`, totalFail > 0 ? RED : DIM)}, ${colorize(`${totalWarn} warnings`, totalWarn > 0 ? YELLOW : DIM)}\n`);
|
|
307
|
+
|
|
308
|
+
process.exit(totalFail > 0 ? 1 : 0);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
main();
|
package/index.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// yaml-validate-cli — programmatic API
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
|
|
6
|
+
function validateYAML(content) {
|
|
7
|
+
const lines = content.split('\n');
|
|
8
|
+
const errors = [];
|
|
9
|
+
const warnings = [];
|
|
10
|
+
const keyTracker = [{}];
|
|
11
|
+
let inBlockScalar = false;
|
|
12
|
+
let blockScalarIndent = -1;
|
|
13
|
+
let prevIndent = 0;
|
|
14
|
+
let indentUnit = null;
|
|
15
|
+
|
|
16
|
+
if (content.charCodeAt(0) === 0xFEFF) {
|
|
17
|
+
warnings.push({ line: 1, msg: 'File starts with BOM' });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
for (let i = 0; i < lines.length; i++) {
|
|
21
|
+
const lineNum = i + 1;
|
|
22
|
+
const raw = lines[i];
|
|
23
|
+
const trimmed = raw.trimEnd();
|
|
24
|
+
const isEmptyOrComment = trimmed.trim() === '' || trimmed.trim().startsWith('#');
|
|
25
|
+
|
|
26
|
+
if (/^\t/.test(raw)) {
|
|
27
|
+
errors.push({ line: lineNum, msg: 'Tabs not allowed as indentation' });
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!isEmptyOrComment) {
|
|
32
|
+
const leadingSpaces = raw.match(/^( +)/);
|
|
33
|
+
if (leadingSpaces && indentUnit === null) indentUnit = leadingSpaces[1].length;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!isEmptyOrComment) {
|
|
37
|
+
const indent = raw.search(/\S/);
|
|
38
|
+
if (inBlockScalar) {
|
|
39
|
+
if (indent <= blockScalarIndent && trimmed.trim() !== '') inBlockScalar = false;
|
|
40
|
+
else continue;
|
|
41
|
+
}
|
|
42
|
+
if (/:\s*[|>][-+]?\s*$/.test(trimmed)) { inBlockScalar = true; blockScalarIndent = indent; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (isEmptyOrComment) continue;
|
|
46
|
+
|
|
47
|
+
const indent = raw.search(/\S/);
|
|
48
|
+
const content_part = raw.trim();
|
|
49
|
+
|
|
50
|
+
if (content_part === '---') { keyTracker.length = 0; keyTracker.push({}); prevIndent = 0; continue; }
|
|
51
|
+
if (content_part === '...') { prevIndent = 0; continue; }
|
|
52
|
+
|
|
53
|
+
const keyMatch = content_part.match(/^([^:'"{\[]+?):\s*(.*)?$/);
|
|
54
|
+
if (keyMatch) {
|
|
55
|
+
const keyName = keyMatch[1].trim();
|
|
56
|
+
const level = indentUnit ? Math.round(indent / indentUnit) : indent;
|
|
57
|
+
while (keyTracker.length <= level) keyTracker.push({});
|
|
58
|
+
if (indent < prevIndent) for (let l = level + 1; l < keyTracker.length; l++) keyTracker[l] = {};
|
|
59
|
+
if (keyTracker[level][keyName]) errors.push({ line: lineNum, msg: `Duplicate key "${keyName}"` });
|
|
60
|
+
else keyTracker[level][keyName] = lineNum;
|
|
61
|
+
prevIndent = indent;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { errors, warnings, valid: errors.length === 0 };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function validateFile(filePath) {
|
|
69
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
70
|
+
return validateYAML(content);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = { validateYAML, validateFile };
|
package/package.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "yaml-validate-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Validate YAML files from the command line with detailed error reporting",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"bin": { "yaml-validate": "./cli.js" },
|
|
7
|
+
"scripts": { "test": "node cli.js --help" },
|
|
8
|
+
"keywords": ["yaml", "validate", "cli", "lint", "devops"],
|
|
9
|
+
"author": "Wilson Xu",
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"dependencies": {}
|
|
12
|
+
}
|