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.
Files changed (3) hide show
  1. package/cli.js +311 -0
  2. package/index.js +73 -0
  3. 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
+ }