yini-cli 1.3.4 → 1.4.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,446 +1,484 @@
1
- import { exit } from 'node:process';
2
- import YINI from 'yini-parser';
3
- const IS_DEBUG = false; // For local debugging purposes, etc.
4
1
  /*
5
- TODO / SHOULD-DO:
6
-
7
- yini validate <file|path...> [options]
8
-
9
- Validate one or more YINI files.
10
- If a directory is provided, all .yini files (case-insensitive) are processed recursively by default.
11
-
12
- On successful validation, a report (summary, issues, optional stats) is printed to the terminal.
13
-
14
- Options
15
- -------
16
-
17
- Validation mode:
18
- --strict Enable strict validation mode
19
- --lenient (default) Enable lenient validation mode
20
- --quiet, -q Suppress normal output (show errors only)
21
- --silent, -s Suppress all output (exit code only)
22
- --stats Include stats, show meta-data section (counts, depth, etc.)
23
- --format <text|yini|json> Output format for the report (staus, stats, issues) (default: text)
24
-
25
- Input handling:
26
- <file> Validate a single YINI file
27
- <path> Validate all .yini files in the directory (recursive by default)
28
- --no-recursive, --no-subdirs Do not descend into subdirectories
29
-
30
- Output handling:
31
- --output <file>, -o <file> Write validation report to file
32
- --overwrite Allow overwriting existing report file
33
- --no-overwrite (default) Prevent overwriting existing report file (default)
34
-
35
- Execution controls (Nice-to-Have)
36
- --fail-fast Stop on the first validation error
37
- --max-errors <n> Stop after <n> errors
38
- --verbose Show detailed processing information
39
- --warnings-as-errors Treat warnings as errors
40
-
41
- Policy controls (advanced):
42
- --duplicates-policy <error|warn|allow> Control handling of duplicate keys / section names
43
- --reserved-policy <error|warn|allow>
44
-
45
- ===========================================================
46
-
47
- REQUIREMENTS for report output:
48
-
49
- The output should:
50
- 1. Be human-readable by default
51
- 2. Give a clear verdict
52
- 3. Show useful context for each problem
53
- 4. Be machine-friendly when requested (--format json)
54
- 5. Be stable and predictable for CI usage
55
-
56
- * Header / Summary:
57
- On success:
58
- ✔ Validation successful
59
- File: config.yini
60
- Mode: lenient
61
- Errors: 0
62
- Warnings: 2
63
-
64
- On failure:
65
- ✖ Validation failed
66
- File: config.yini
67
- Mode: strict
68
- Errors: 3
69
- Warnings: 1
70
-
71
- * Issues sections:
72
- Each issue:
73
- Severity error / warning
74
- Code stable identifier (DUPLICATE_KEY, UNKNOWN_CONSTRUCT, etc.)
75
- Message short explanation
76
- Location file + line + column
77
- Context snippet of the file (if helpful)
78
-
79
- Example:
80
- Errors:
81
- [E001] Duplicate key "host"
82
- at config.yini:14:5
83
- Previous definition at line 7
84
- → host = "localhost"
85
-
86
- Warnings:
87
- [W002] Reserved construct used: "$schema"
88
- at config.yini:3:1
89
-
90
- * Optional meta-data section --stats
91
- Statistics:
92
- Sections: 5
93
- Keys: 27
94
- Arrays: 4
95
- Objects: 3
96
- Nesting depth: 4
97
-
98
- * Exit code contract
99
- Success, no warnings 0
100
- Warnings only 0 (or 1 if --warnings-as-errors)
101
- Errors 1
102
-
103
- Example: JSON format (--format json)
104
- {
105
- "file": "config.yini",
106
- "mode": "strict",
107
- "summary": {
108
- "errors": 2,
109
- "warnings": 1
110
- },
111
- "issues": [
112
- {
113
- "severity": "error",
114
- "code": "DUPLICATE_KEY",
115
- "message": "Duplicate key \"host\"",
116
- "location": { "line": 14, "column": 5 }
117
- },
118
- {
119
- "severity": "error",
120
- "code": "INVALID_TYPE",
121
- "message": "Expected number, got string",
122
- "location": { "line": 22, "column": 12 }
123
- },
124
- {
125
- "severity": "warning",
126
- "code": "RESERVED_CONSTRUCT",
127
- "message": "Reserved construct \"$schema\"",
128
- "location": { "line": 3, "column": 1 }
2
+ * validateCommand.ts
3
+ *
4
+ * Behavioral spec and output conventions:
5
+ * see docs/cli/validate-command.md
6
+ */
7
+ // src/commands/validateCommand.ts
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import YINI from 'yini-parser';
11
+ import { getDisplayBaseDir, printStderr, printStdout, resolveRunModeFromTargets, resolveStrictMode, toDisplayPath, } from './commonFunctions.js';
12
+ // --- Public entrypoint -------------------------------------------------------
13
+ export const validateTargets = (targets, options) => {
14
+ try {
15
+ const strictMode = resolveStrictMode(options);
16
+ const mode = strictMode ? 'strict' : 'lenient';
17
+ const runMode = resolveRunModeFromTargets(targets);
18
+ const recursive = options.recursive ?? true;
19
+ const files = collectFilesFromTargets(targets, recursive);
20
+ if (!files.length) {
21
+ throw new Error('No YINI files found to validate.');
22
+ }
23
+ const displayBaseDir = getDisplayBaseDir(targets);
24
+ const aggregate = {
25
+ runMode,
26
+ mode,
27
+ status: 'passed',
28
+ filesChecked: 0,
29
+ failedFiles: 0,
30
+ errors: 0,
31
+ warnings: 0,
32
+ notices: 0,
33
+ infos: 0,
34
+ displayBaseDir,
35
+ results: [],
36
+ };
37
+ let totalErrorsSeen = 0;
38
+ for (const file of files) {
39
+ const result = validateOneFile(file, options);
40
+ aggregate.results.push(result);
41
+ aggregate.filesChecked += 1;
42
+ aggregate.errors += result.errors;
43
+ aggregate.warnings += result.warnings;
44
+ aggregate.notices += result.notices;
45
+ aggregate.infos += result.infos;
46
+ if (result.status === 'failed') {
47
+ aggregate.failedFiles += 1;
48
+ }
49
+ totalErrorsSeen += result.errors;
50
+ if (options.failFast && result.status === 'failed') {
51
+ break;
52
+ }
53
+ if (typeof options.maxErrors === 'number' &&
54
+ totalErrorsSeen >= options.maxErrors) {
55
+ break;
129
56
  }
130
- ],
131
- "stats": {
132
- "sections": 5,
133
- "keys": 27,
134
- "nestingDepth": 4
135
57
  }
58
+ aggregate.status = resolveAggregateStatus(aggregate, options);
59
+ if (!options.silent) {
60
+ if (options.format === 'json') {
61
+ printStdout(options, formatJsonReport(aggregate, options));
62
+ }
63
+ else {
64
+ printTextReport(aggregate, options);
65
+ }
136
66
  }
137
-
138
- */
139
- export const validateFile = (file, commandOptions = {}) => {
140
- let parsedResult = undefined;
141
- let isCatchedError = true;
67
+ process.exit(getValidationExitCode(aggregate, options));
68
+ }
69
+ catch (err) {
70
+ const message = err instanceof Error ? err.message : String(err);
71
+ printStderr(options, `Error: ${message}`);
72
+ process.exit(2);
73
+ }
74
+ };
75
+ // --- File collection ---------------------------------------------------------
76
+ const collectFilesFromTargets = (targets, recursive) => {
77
+ const out = new Set();
78
+ for (const target of targets) {
79
+ const resolved = path.resolve(target);
80
+ if (!fs.existsSync(resolved)) {
81
+ throw new Error(`Path does not exist: "${target}"`);
82
+ }
83
+ const stat = fs.statSync(resolved);
84
+ if (stat.isFile()) {
85
+ out.add(resolved);
86
+ continue;
87
+ }
88
+ if (!stat.isDirectory()) {
89
+ throw new Error(`Not a file or directory: "${target}"`);
90
+ }
91
+ for (const file of collectFilesFromDirectory(resolved, recursive)) {
92
+ out.add(file);
93
+ }
94
+ }
95
+ return [...out].sort((a, b) => a.localeCompare(b));
96
+ };
97
+ const collectFilesFromDirectory = (dirPath, recursive) => {
98
+ const results = [];
99
+ for (const entry of fs.readdirSync(dirPath)) {
100
+ const fullPath = path.join(dirPath, entry);
101
+ const stat = fs.statSync(fullPath);
102
+ if (stat.isDirectory()) {
103
+ if (recursive) {
104
+ results.push(...collectFilesFromDirectory(fullPath, recursive));
105
+ }
106
+ continue;
107
+ }
108
+ if (stat.isFile() && fullPath.toLowerCase().endsWith('.yini')) {
109
+ results.push(fullPath);
110
+ }
111
+ }
112
+ return results;
113
+ };
114
+ // --- Validation --------------------------------------------------------------
115
+ const validateOneFile = (file, options) => {
116
+ const strictMode = resolveStrictMode(options);
142
117
  const parseOptions = {
143
- strictMode: commandOptions.strict ?? false,
118
+ strictMode,
144
119
  failLevel: 'ignore-errors',
145
120
  includeMetadata: true,
146
121
  includeDiagnostics: true,
147
122
  silent: true,
148
123
  };
149
124
  try {
150
- parsedResult = YINI.parseFile(file, parseOptions);
151
- isCatchedError = false;
125
+ const parsed = YINI.parseFile(file, parseOptions);
126
+ const metadata = parsed?.meta ?? null;
127
+ const diagnostics = metadata?.diagnostics;
128
+ if (!diagnostics) {
129
+ return {
130
+ file,
131
+ mode: strictMode ? 'strict' : 'lenient',
132
+ status: 'failed',
133
+ errors: 1,
134
+ warnings: 0,
135
+ notices: 0,
136
+ infos: 0,
137
+ issues: [
138
+ {
139
+ severity: 'error',
140
+ code: 'MISSING_DIAGNOSTICS',
141
+ message: 'Missing diagnostics metadata.',
142
+ line: 0,
143
+ column: 0,
144
+ },
145
+ ],
146
+ metadata,
147
+ fatalError: 'Missing diagnostics metadata.',
148
+ };
149
+ }
150
+ const issues = [
151
+ ...mapIssuePayloadArray('error', diagnostics.errors.payload),
152
+ ...mapIssuePayloadArray('warning', diagnostics.warnings.payload),
153
+ ...mapIssuePayloadArray('notice', diagnostics.notices.payload),
154
+ ...mapIssuePayloadArray('info', diagnostics.infos.payload),
155
+ ];
156
+ const errors = diagnostics.errors.errorCount;
157
+ const warnings = diagnostics.warnings.warningCount;
158
+ const notices = diagnostics.notices.noticeCount;
159
+ const infos = diagnostics.infos.infoCount;
160
+ return {
161
+ file,
162
+ mode: metadata?.mode === 'strict' || metadata?.mode === 'lenient'
163
+ ? metadata.mode
164
+ : 'custom',
165
+ status: resolveFileStatus(errors, warnings, options),
166
+ errors,
167
+ warnings,
168
+ notices,
169
+ infos,
170
+ issues,
171
+ metadata,
172
+ };
152
173
  }
153
174
  catch (err) {
154
- isCatchedError = true;
155
- if (!commandOptions.silent) {
156
- const message = err instanceof Error ? err.message : String(err);
157
- console.error(`Error: ${message}`);
158
- }
175
+ const message = err instanceof Error ? err.message : String(err);
176
+ return {
177
+ file,
178
+ mode: strictMode ? 'strict' : 'lenient',
179
+ status: 'failed',
180
+ errors: 1,
181
+ warnings: 0,
182
+ notices: 0,
183
+ infos: 0,
184
+ issues: [
185
+ {
186
+ severity: 'error',
187
+ code: 'RUNTIME_ERROR',
188
+ message,
189
+ line: 0,
190
+ column: 0,
191
+ },
192
+ ],
193
+ metadata: null,
194
+ fatalError: message,
195
+ };
159
196
  }
160
- let metadata = null;
161
- let errors = 0;
162
- let warnings = 0;
163
- let notices = 0;
164
- let infos = 0;
165
- if (!isCatchedError && parsedResult?.meta) {
166
- metadata = parsedResult?.meta;
167
- // assert(metadata) // Make sure there is metadata!
168
- // printObject(metadata, true)
169
- // assert(metadata.diagnostics)
170
- if (!metadata?.diagnostics) {
171
- console.error('Internal error: Missing diagnostics metadata');
172
- exit(1);
173
- }
174
- const diag = metadata.diagnostics;
175
- errors = diag.errors.errorCount;
176
- warnings = diag.warnings.warningCount;
177
- notices = diag.notices.noticeCount;
178
- infos = diag.infos.infoCount;
197
+ };
198
+ const mapIssuePayloadArray = (severity, payload) => {
199
+ return payload.map((item) => ({
200
+ severity,
201
+ code: toStableIssueCode(item.typeKey, severity),
202
+ message: item.message ?? 'Unknown issue.',
203
+ line: item.line ?? 0,
204
+ column: item.column ?? 0,
205
+ advice: item.advice,
206
+ hint: item.hint,
207
+ }));
208
+ };
209
+ const toStableIssueCode = (typeKey, fallbackSeverity) => {
210
+ const base = typeKey && typeKey.trim() ? typeKey : fallbackSeverity;
211
+ return base
212
+ .replace(/[^a-zA-Z0-9]+/g, '_')
213
+ .replace(/^_+|_+$/g, '')
214
+ .toUpperCase();
215
+ };
216
+ // --- Status / exit code resolution ------------------------------------------
217
+ const resolveFileStatus = (errors, warnings, options) => {
218
+ if (errors > 0)
219
+ return 'failed';
220
+ if (options.warningsAsErrors && warnings > 0)
221
+ return 'failed';
222
+ if (warnings > 0)
223
+ return 'passed-with-warnings';
224
+ return 'passed';
225
+ };
226
+ const resolveAggregateStatus = (aggregate, options) => {
227
+ if (aggregate.failedFiles > 0)
228
+ return 'failed';
229
+ if (options.warningsAsErrors && aggregate.warnings > 0)
230
+ return 'failed';
231
+ if (aggregate.warnings > 0)
232
+ return 'passed-with-warnings';
233
+ return 'passed';
234
+ };
235
+ const getValidationExitCode = (aggregate, options) => {
236
+ if (aggregate.failedFiles > 0)
237
+ return 1;
238
+ if (options.warningsAsErrors && aggregate.warnings > 0)
239
+ return 1;
240
+ return 0;
241
+ };
242
+ // --- Text output -------------------------------------------------------------
243
+ const printTextReport = (aggregate, options) => {
244
+ if (aggregate.runMode === 'directory') {
245
+ printDirectoryModeTextReport(aggregate, options);
246
+ return;
179
247
  }
180
- IS_DEBUG && console.log();
181
- IS_DEBUG && console.log('isCatchedError = ' + isCatchedError);
182
- IS_DEBUG && console.log('TEMP OUTPUT');
183
- IS_DEBUG && console.log(' errors = ' + errors);
184
- IS_DEBUG && console.log('warnings = ' + warnings);
185
- IS_DEBUG && console.log(' notices = ' + notices);
186
- IS_DEBUG && console.log(' infos = ' + infos);
187
- IS_DEBUG && console.log('metadata = ' + metadata);
188
- IS_DEBUG &&
189
- console.log('includeMetadata = ' +
190
- metadata?.diagnostics?.effectiveOptions.includeMetadata);
191
- IS_DEBUG && console.log('commandOptions.stats = ' + commandOptions?.stats);
192
- IS_DEBUG && console.log();
193
- //state returned:
194
- // - passed (no errors/warnings),
195
- // - finished (with warnings, no errors) / or - passed with warnings
196
- // - failed (errors),
197
- if (isCatchedError) {
198
- errors = 1;
248
+ if (aggregate.results.length === 1) {
249
+ printSingleFileTextReport(aggregate.results[0], options);
250
+ return;
199
251
  }
200
- // console.log()
201
- let statusType;
202
- if (errors) {
203
- statusType = 'Failed';
252
+ printFileModeTextReport(aggregate, options);
253
+ };
254
+ const printSingleFileTextReport = (result, options) => {
255
+ const totalIssues = result.errors + result.warnings + result.notices + result.infos;
256
+ if (result.status === 'failed') {
257
+ const suffix = totalIssues > 0 ? ` (${totalIssues} issues)` : '';
258
+ printStdout(options, `✖ Validation failed${suffix}`);
204
259
  }
205
- else if (warnings) {
206
- statusType = 'Passed-with-Warnings';
260
+ else if (result.status === 'passed-with-warnings') {
261
+ printStdout(options, '✔ Validation successful (with warnings)');
207
262
  }
208
263
  else {
209
- statusType = 'Passed';
264
+ printStdout(options, '✔ Validation successful');
210
265
  }
211
- const jsonSummary = toSummaryJson(statusType, file,
212
- // metadata,
213
- metadata?.mode ?? 'custom', errors, warnings, notices, infos);
214
- printSummary(jsonSummary);
215
- // if (errors) {
216
- // // red ✖
217
- // console.error(
218
- // formatToSummary('Failed', errors, warnings, notices, infos),
219
- // )
220
- // // exit(1)
221
- // } else if (warnings) {
222
- // // yellow ⚠️
223
- // console.warn(
224
- // formatToSummary(
225
- // 'Passed-with-Warnings',
226
- // errors,
227
- // warnings,
228
- // notices,
229
- // infos,
230
- // ),
231
- // )
232
- // // exit(0)
233
- // } else {
234
- // // green ✔
235
- // console.log(formatToSummary('Passed', errors, warnings, notices, infos))
236
- // // exit(0)
237
- // }
238
- // Print optional Stats-report if "--stats" was given.
239
- if (!commandOptions.silent && !isCatchedError) {
240
- // if (commandOptions.details) {
241
- if (errors || warnings) {
242
- if (!metadata) {
243
- console.error('Internal error: No metadata available');
244
- exit(1);
245
- }
246
- // assert(metadata) // Make sure there is metadata!
247
- console.log();
248
- printIssuesFound(file, metadata);
266
+ printStdout(options, '');
267
+ printStdout(options, `File: "${result.file}"`);
268
+ printStdout(options, `Mode: ${result.mode}`);
269
+ printStdout(options, `Errors: ${result.errors}`);
270
+ printStdout(options, `Warnings: ${result.warnings}`);
271
+ printIssueDetailsForResult(result, options);
272
+ if (options.stats && result.metadata) {
273
+ printStdout(options, '');
274
+ printStdout(options, formatStatsReport(result.metadata));
275
+ }
276
+ };
277
+ const printFileModeTextReport = (aggregate, options) => {
278
+ for (const result of aggregate.results) {
279
+ if (result.status === 'failed') {
280
+ printStdout(options, `FAIL "${result.file}"`);
249
281
  }
250
- if (commandOptions.stats) {
251
- if (!metadata) {
252
- console.error('Internal error: No metadata available');
253
- exit(1);
254
- }
255
- console.log();
256
- console.log(formatToStatsReport(file, metadata).trim());
282
+ else if (!options.quiet) {
283
+ printStdout(options, `OK "${result.file}"`);
257
284
  }
258
285
  }
259
- if (errors) {
260
- exit(1);
286
+ printStdout(options, '');
287
+ printStdout(options, `Mode: ${aggregate.mode}`);
288
+ printStdout(options, `Summary: ${aggregate.filesChecked} checked, ${aggregate.failedFiles} failed, ${aggregate.errors} errors, ${aggregate.warnings} warnings`);
289
+ for (const result of aggregate.results) {
290
+ if (result.status !== 'failed')
291
+ continue;
292
+ printIssueDetailsForResult(result, options);
261
293
  }
262
- exit(0);
263
294
  };
264
- /**
265
- * @returns Map to a short summary.
266
- */
267
- const toSummaryJson = (statusType, fileWithPath,
268
- // metadata: ResultMetadata | null,
269
- mode, errors, warnings, notices, infos) => {
270
- let result = '';
271
- switch (statusType) {
272
- case 'Passed':
273
- // result = '✔ Validation passed'
274
- result = '✔ Validation successful';
275
- break;
276
- case 'Passed-with-Warnings':
277
- // result = '⚠️ Validation finished'
278
- result = '✔ Validation successful (with warnings)';
279
- break;
280
- case 'Failed':
281
- // result = '✖ Validation failed'
282
- result = '✖ Validation failed';
283
- break;
295
+ const printDirectoryModeTextReport = (aggregate, options) => {
296
+ for (const result of aggregate.results) {
297
+ const displayPath = toDisplayPath(result.file, aggregate.displayBaseDir);
298
+ if (result.status === 'failed') {
299
+ printStdout(options, `FAIL "${displayPath}"`);
300
+ }
301
+ else if (!options.quiet) {
302
+ printStdout(options, `OK "${displayPath}"`);
303
+ }
304
+ }
305
+ printStdout(options, '');
306
+ printStdout(options, `Base: "${aggregate.displayBaseDir}"`);
307
+ printStdout(options, `Mode: ${aggregate.mode}`);
308
+ printStdout(options, `Summary: ${aggregate.filesChecked} checked, ${aggregate.failedFiles} failed, ${aggregate.errors} errors, ${aggregate.warnings} warnings`);
309
+ for (const result of aggregate.results) {
310
+ if (result.status !== 'failed')
311
+ continue;
312
+ printIssueDetailsForResult(result, options, aggregate.displayBaseDir);
313
+ }
314
+ };
315
+ const printIssueDetailsForResult = (result, options, displayBaseDir) => {
316
+ const printable = result.issues.filter((issue) => issue.severity === 'error' || issue.severity === 'warning');
317
+ if (!printable.length)
318
+ return;
319
+ const displayPath = displayBaseDir
320
+ ? toDisplayPath(result.file, displayBaseDir)
321
+ : result.file;
322
+ printStderr(options, '');
323
+ printStderr(options, `"${displayPath}"`);
324
+ printable.forEach((issue, index) => {
325
+ const hasLocation = typeof issue.line === 'number' &&
326
+ typeof issue.column === 'number' &&
327
+ issue.line > 0 &&
328
+ issue.column > 0;
329
+ const location = hasLocation
330
+ ? `${issue.line}:${issue.column}`.padEnd(7)
331
+ : ''.padEnd(7);
332
+ const severity = issue.severity.padEnd(8);
333
+ printStderr(options, hasLocation
334
+ ? ` ${location} ${severity}${issue.message}`
335
+ : ` ${severity}${issue.message}`);
336
+ if (issue.advice && !options.quiet) {
337
+ printStderr(options, ` ${issue.advice}`);
338
+ }
339
+ if (issue.hint && !options.quiet) {
340
+ printStderr(options, ` ${issue.hint}`);
341
+ }
342
+ if (index < printable.length - 1) {
343
+ printStderr(options, '');
344
+ }
345
+ });
346
+ };
347
+ // --- JSON output -------------------------------------------------------------
348
+ const formatJsonReport = (aggregate, options) => {
349
+ if (aggregate.runMode === 'directory') {
350
+ return JSON.stringify(toDirectoryModeJsonReport(aggregate, options), null, 2);
351
+ }
352
+ if (aggregate.results.length === 1) {
353
+ return JSON.stringify(toSingleFileJsonReport(aggregate.results[0], options), null, 2);
284
354
  }
285
- // const diag = metadata.diagnostics
286
- const issuesCount = errors + warnings + notices + infos;
355
+ return JSON.stringify(toFileModeJsonReport(aggregate, options), null, 2);
356
+ };
357
+ const toSingleFileJsonReport = (result, options) => {
287
358
  return {
288
- result: result,
289
- file: fileWithPath,
290
- mode: mode,
359
+ file: result.file,
360
+ runMode: 'file',
361
+ mode: result.mode,
362
+ status: result.status,
291
363
  summary: {
292
- issuesCount: issuesCount,
293
- errors,
294
- warnings,
295
- notices,
296
- infos,
364
+ errors: result.errors,
365
+ warnings: result.warnings,
366
+ notices: result.notices,
367
+ infos: result.infos,
297
368
  },
369
+ issues: result.issues.map(mapIssueToJson),
370
+ stats: toStatsJson(result.metadata, options),
298
371
  };
299
372
  };
300
- const printSummary = (sum) => {
301
- let str = '';
302
- str += sum.result + '\n';
303
- str += '\n';
304
- str += `File: "${sum.file}"\n`;
305
- str += `Mode: ${sum.mode.toLowerCase()}\n`;
306
- str += `Errors: ${sum.summary.errors}\n`;
307
- str += `Warnings: ${sum.summary.warnings}\n`;
308
- if (sum.summary.notices) {
309
- str += `Notices: ${sum.summary.notices}\n`;
310
- }
311
- if (sum.summary.infos) {
312
- str += `Infos: ${sum.summary.infos}\n`;
313
- }
314
- str += `Total issues: ${sum.summary.issuesCount}\n`;
315
- console.log(str);
373
+ const toFileModeJsonReport = (aggregate, options) => {
374
+ return {
375
+ runMode: 'file',
376
+ mode: aggregate.mode,
377
+ status: aggregate.status,
378
+ summary: {
379
+ filesChecked: aggregate.filesChecked,
380
+ failedFiles: aggregate.failedFiles,
381
+ errors: aggregate.errors,
382
+ warnings: aggregate.warnings,
383
+ notices: aggregate.notices,
384
+ infos: aggregate.infos,
385
+ },
386
+ files: aggregate.results.map((result) => ({
387
+ file: result.file,
388
+ mode: result.mode,
389
+ status: result.status,
390
+ summary: {
391
+ errors: result.errors,
392
+ warnings: result.warnings,
393
+ notices: result.notices,
394
+ infos: result.infos,
395
+ },
396
+ issues: result.issues.map(mapIssueToJson),
397
+ stats: toStatsJson(result.metadata, options),
398
+ })),
399
+ };
316
400
  };
317
- /**
318
- * @deprecated Use toSummaryJson(..), this TO BE deleted.
319
- */
320
- const formatToSummary = (statusType, errors, warnings, notices, infos) => {
321
- const totalMsgs = errors + warnings + notices + infos;
322
- let str = ``;
323
- switch (statusType) {
324
- case 'Passed':
325
- str = '✔ Validation passed';
326
- break;
327
- case 'Passed-with-Warnings':
328
- str = '⚠️ Validation finished';
329
- break;
330
- case 'Failed':
331
- str = '✖ Validation failed';
332
- break;
401
+ const toDirectoryModeJsonReport = (aggregate, options) => {
402
+ return {
403
+ base: aggregate.displayBaseDir,
404
+ runMode: 'directory',
405
+ mode: aggregate.mode,
406
+ status: aggregate.status,
407
+ summary: {
408
+ filesChecked: aggregate.filesChecked,
409
+ failedFiles: aggregate.failedFiles,
410
+ errors: aggregate.errors,
411
+ warnings: aggregate.warnings,
412
+ notices: aggregate.notices,
413
+ infos: aggregate.infos,
414
+ },
415
+ files: aggregate.results.map((result) => ({
416
+ file: toDisplayPath(result.file, aggregate.displayBaseDir),
417
+ mode: result.mode,
418
+ status: result.status,
419
+ summary: {
420
+ errors: result.errors,
421
+ warnings: result.warnings,
422
+ notices: result.notices,
423
+ infos: result.infos,
424
+ },
425
+ issues: result.issues.map(mapIssueToJson),
426
+ stats: toStatsJson(result.metadata, options),
427
+ })),
428
+ };
429
+ };
430
+ const mapIssueToJson = (issue) => {
431
+ const hasLocation = typeof issue.line === 'number' &&
432
+ typeof issue.column === 'number' &&
433
+ issue.line > 0 &&
434
+ issue.column > 0;
435
+ return {
436
+ severity: issue.severity,
437
+ code: issue.code,
438
+ message: issue.message,
439
+ location: hasLocation
440
+ ? {
441
+ line: issue.line,
442
+ column: issue.column,
443
+ }
444
+ : undefined,
445
+ advice: issue.advice,
446
+ hint: issue.hint,
447
+ };
448
+ };
449
+ const toStatsJson = (metadata, options) => {
450
+ if (!options.stats || !metadata) {
451
+ return undefined;
333
452
  }
334
- str += ` (${errors} errors, ${warnings} warnings, ${totalMsgs} total messages)`;
335
- return str;
453
+ return {
454
+ lineCount: metadata.source.lineCount,
455
+ byteSize: metadata.source.sourceType === 'inline'
456
+ ? null
457
+ : metadata.source.byteSize,
458
+ sectionCount: metadata.structure.sectionCount,
459
+ memberCount: metadata.structure.memberCount,
460
+ nestingDepth: metadata.structure.maxDepth,
461
+ hasYiniMarker: metadata.source.hasYiniMarker,
462
+ hasDocumentTerminator: metadata.source.hasDocumentTerminator,
463
+ };
336
464
  };
337
- // --- Format to a Stats report --------------------------------------------------------
338
- //@todo format parsed.meta to report as
339
- /*
340
- - Produce a summary-level validation report.
341
- - Output is structured and concise (e.g. JSON or table-like).
342
- - Focus on counts, pass/fail, severity summary.
343
-
344
- Example:
345
- Validation report for config.yini:
346
- Errors: 3
347
- Warnings: 1
348
- Notices: 0
349
- Result: INVALID
350
- */
351
- const formatToStatsReport = (fileWithPath, metadata) => {
352
- // console.log('formatToStatsReport(..)')
353
- // printObject(metadata)
354
- // console.log()
465
+ // --- Stats -------------------------------------------------------------------
466
+ const formatStatsReport = (metadata) => {
355
467
  if (!metadata?.diagnostics) {
356
- console.error('Internal error: Missing diagnostics');
357
- exit(1);
468
+ throw new Error('Internal error: Missing diagnostics metadata.');
358
469
  }
359
470
  const diag = metadata.diagnostics;
360
- const issuesCount = diag.errors.errorCount +
361
- diag.warnings.warningCount +
362
- diag.notices.noticeCount +
363
- diag.infos.infoCount;
364
- const str = `Validation Report
365
- =================
366
-
367
- File "${fileWithPath}"
368
- Issues: ${issuesCount}
369
-
370
- Summary
371
- -------
372
- Mode: ${metadata.mode}
373
- Strict: ${metadata.mode === 'strict'}
374
-
375
- Errors: ${diag.errors.errorCount}
376
- Warnings: ${diag.warnings.warningCount}
377
- Notices: ${diag.notices.noticeCount}
378
- Infos: ${diag.infos.infoCount}
379
-
380
- Stats
381
- -----
471
+ return `Statistics
472
+ ----------
473
+ Notices: ${diag.notices.noticeCount}
474
+ Infos: ${diag.infos.infoCount}
382
475
  Line Count: ${metadata.source.lineCount}
383
476
  Section Count: ${metadata.structure.sectionCount}
384
477
  Member Count: ${metadata.structure.memberCount}
385
478
  Nesting Depth: ${metadata.structure.maxDepth}
386
-
387
479
  Has @YINI: ${metadata.source.hasYiniMarker}
388
480
  Has /END: ${metadata.source.hasDocumentTerminator}
389
- Byte Size: ${metadata.source.sourceType === 'inline' ? 'n/a' : metadata.source.byteSize + ' bytes'}
390
- `;
391
- return str;
392
- };
393
- // -------------------------------------------------------------------------
394
- // --- Format to a Details --------------------------------------------------------
395
- //@todo format parsed.meta to details as
396
- /*
397
- - Show full detailed validation messages.
398
- - Output includes line numbers, columns, error codes, and descriptive text.
399
- - Useful for debugging YINI files.
400
-
401
- Example:
402
- Error at line 5, column 9: Unexpected '/END' — expected <EOF>
403
- Warning at line 10, column 3: Section level skipped (0 → 2)
404
- Notice at line 1: Unused @yini directive
405
- */
406
- const printIssuesFound = (fileWithPath, metadata) => {
407
- // console.log('printDetails(..)')
408
- // printObject(metadata)
409
- // console.log(toPrettyJSON(metadata))
410
- // console.log()
411
- if (!metadata?.diagnostics) {
412
- console.error('Internal error: Missing diagnostics metadata');
413
- exit(1);
414
- }
415
- const diag = metadata.diagnostics;
416
- IS_DEBUG && console.log('*** Issues Found');
417
- IS_DEBUG && console.log('*** ------------');
418
- IS_DEBUG && console.log();
419
- const errors = diag.errors.payload;
420
- printIssues('Error ', 'E', errors);
421
- const warnings = diag.warnings.payload;
422
- printIssues('Warning', 'W', warnings);
423
- const notices = diag.notices.payload;
424
- printIssues('Notice ', 'N', notices);
425
- const infos = diag.infos.payload;
426
- printIssues('Info ', 'I', infos);
427
- return;
428
- };
429
- // -------------------------------------------------------------------------
430
- const printIssues = (typeLabel, prefix, issues) => {
431
- const leftPadding = ' ';
432
- issues.forEach((iss, i) => {
433
- const id = '#' + prefix + '-0' + (i + 1);
434
- // const id: string = '' + prefix + '-0' + (i+1) + ':'
435
- let str = `${typeLabel} [${id}]:\n`;
436
- str +=
437
- leftPadding +
438
- `At line ${iss.line}, column ${iss.column}: ${iss.message}`;
439
- if (iss.advice)
440
- str += '\n' + leftPadding + iss.advice;
441
- if (iss.hint)
442
- str += '\n' + leftPadding + iss.hint;
443
- console.log(str);
444
- console.log();
445
- });
481
+ Byte Size: ${metadata.source.sourceType === 'inline'
482
+ ? 'n/a'
483
+ : `${metadata.source.byteSize} bytes`}`;
446
484
  };