@gefyra/complies-with-eu 1.2.0 → 1.3.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.
@@ -12,13 +12,14 @@ const {
12
12
  addMetaProfileClaims,
13
13
  } = require('./profileUtils');
14
14
  const { runValidatorBatch } = require('./javaValidator');
15
+ const { oneLineError } = require('./validatorOutput');
15
16
  const { resolvePackageId, resolvePackageDependencyDirs } = require('./fhirPkgTool');
16
17
  const { generateReport, mdCode, mdText, exampleErrorCount } = require('./reportGen');
17
18
  const { checkObligations } = require('./obligationCheck');
18
19
  const { evaluateExistsOrConstraints, loadEuSds } = require('./constraintCheck');
19
20
  const { readInstalledVersionsSync } = require('./toolDownloader');
20
21
 
21
- const stderrLog = (...args) => process.stderr.write(args.join(' ') + '\n');
22
+ const stderrLog = require('./output').makeProgressLogger();
22
23
 
23
24
  /**
24
25
  * Parses "packageName[@version]" (or "packageName#version") into { packageName, version }.
@@ -184,10 +185,11 @@ async function postProcessProfile({
184
185
 
185
186
  // Evaluate exists()-or constraints
186
187
  let filteredNonCompliantMap;
187
- let autoSatisfiedConstraints = [];
188
+ let autoSatisfiedConstraints;
189
+ let euSdByCanonical = new Map();
188
190
  try {
189
191
  const rawNonCompliantMap = collectNonCompliant(parsed.issues);
190
- const euSdByCanonical = await loadEuSds(euPackageDirs, euCanonicals);
192
+ euSdByCanonical = await loadEuSds(euPackageDirs, euCanonicals);
191
193
  ({ filteredMap: filteredNonCompliantMap, satisfied: autoSatisfiedConstraints } =
192
194
  evaluateExistsOrConstraints({ inputSd: profile, euSdByCanonical, nonCompliantMap: rawNonCompliantMap }));
193
195
  } catch {
@@ -209,6 +211,7 @@ async function postProcessProfile({
209
211
  obligationViolationsByEuCanonical,
210
212
  filteredNonCompliantMap,
211
213
  autoSatisfiedConstraints,
214
+ euSdByCanonical,
212
215
  });
213
216
 
214
217
  // Derive compliance
@@ -237,8 +240,10 @@ async function postProcessProfile({
237
240
  * @param {string} params.examplesDir - Directory for example artifacts
238
241
  * @param {string[]} params.euCanonicals - EU canonical URLs
239
242
  * @param {string[]} params.igIds - IG identifiers for the validator
240
- * @param {boolean} params.debug - Debug flag
243
+ * @param {boolean} params.debug - Debug flag (implies verbose)
244
+ * @param {boolean} [params.verbose] - Stream the full validator output
241
245
  * @param {Function} [params.log] - Progress logger (default: stderr)
246
+ * @param {Function} [params.runBatch] - runValidatorBatch replacement (tests)
242
247
  * @returns {Promise<{exampleResults: Array, exReportPath: string}|null>}
243
248
  */
244
249
  async function runExamplesForProfile({
@@ -249,7 +254,9 @@ async function runExamplesForProfile({
249
254
  euCanonicals,
250
255
  igIds,
251
256
  debug = false,
257
+ verbose = false,
252
258
  log = stderrLog,
259
+ runBatch = runValidatorBatch,
253
260
  }) {
254
261
  let examples;
255
262
  try {
@@ -282,11 +289,11 @@ async function runExamplesForProfile({
282
289
  let batchResults;
283
290
  let batchError = null;
284
291
  try {
285
- ({ results: batchResults } = await runValidatorBatch(examplesDir, igIds, debug, resolveArgs));
292
+ ({ results: batchResults } = await runBatch(examplesDir, igIds, { debug, verbose }, resolveArgs));
286
293
  } catch (err) {
287
294
  log(chalk.red(`Example validation failed: ${err.message}`));
288
295
  batchResults = [];
289
- batchError = err.message;
296
+ batchError = oneLineError(err); // one line: repeated per example and inlined in the report
290
297
  }
291
298
 
292
299
  // Map results; a missing result is kept as summary:null and reported as failed
@@ -0,0 +1,276 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Handling of the HL7 FHIR Validator console output: by default the raw output
5
+ * (hundreds of "Information @ ..." lines) is NOT mirrored to the terminal. It is
6
+ * collected (and written to a log file by the callers) while a concise progress
7
+ * indicator on stderr shows coarse phase hints. With --verbose (or --debug) the
8
+ * raw output is streamed to stderr as before.
9
+ *
10
+ * Everything here is free of Java/child-process dependencies so it can be unit-tested.
11
+ */
12
+
13
+ const ora = require('ora');
14
+ const chalk = require('chalk');
15
+
16
+ // eslint-disable-next-line no-control-regex
17
+ const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]/g;
18
+
19
+ /** Number of trailing output lines included in error messages. */
20
+ const TAIL_LINES = 20;
21
+
22
+ /** Removes ANSI escape codes. */
23
+ function stripAnsi(text) {
24
+ return String(text).replace(ANSI_RE, '');
25
+ }
26
+
27
+ /**
28
+ * Coarse phases of a validator run. The validator connects to the terminology
29
+ * server after loading the core packages and only then loads the -ig packages
30
+ * (more "Load ..." / "Installing ..." lines), so until "Validating" is reached
31
+ * the label follows the most recent hint in either direction. "Validating" is
32
+ * final: later lines never move the progress back.
33
+ */
34
+ const PHASES = [
35
+ { label: 'Loading packages', re: /^\s*(?:Loading\b|Load\s|Installing\s|Package Summary)/ },
36
+ { label: 'Connecting to terminology server', re: /^\s*Terminology server\b/ },
37
+ { label: 'Validating', re: /^\s*(?:Validating\b|Validate\s|-- Validating\b|Get set)/, final: true },
38
+ ];
39
+
40
+ /**
41
+ * Returns the index into PHASES that a single output line indicates, or -1.
42
+ * @param {string} line
43
+ * @returns {number}
44
+ */
45
+ function detectPhase(line) {
46
+ const clean = stripAnsi(line);
47
+ for (let i = PHASES.length - 1; i >= 0; i--) {
48
+ if (PHASES[i].re.test(clean)) return i;
49
+ }
50
+ return -1;
51
+ }
52
+
53
+ /**
54
+ * Extracts the most useful closing information from the raw output: the last
55
+ * "*FAILURE*: ..." or "*SUCCESS*: ..." summary line and the "Done. Times: ..." line.
56
+ * @param {string} rawOutput
57
+ * @returns {{ summaryLine: string|null, doneLine: string|null, summaryCount: number }}
58
+ */
59
+ function extractResultInfo(rawOutput) {
60
+ let summaryLine = null;
61
+ let doneLine = null;
62
+ let summaryCount = 0;
63
+ for (const line of stripAnsi(rawOutput).split(/\r\n|\r|\n/)) {
64
+ if (/^\*(FAILURE|SUCCESS)\*:/.test(line)) {
65
+ summaryLine = line.trim();
66
+ summaryCount++;
67
+ } else if (/^\s*Done\. Times:/.test(line)) {
68
+ doneLine = line.trim();
69
+ }
70
+ }
71
+ return { summaryLine, doneLine, summaryCount };
72
+ }
73
+
74
+ /**
75
+ * Returns the last `n` non-empty lines of the output (ANSI codes removed).
76
+ * @param {string} rawOutput
77
+ * @param {number} [n]
78
+ * @returns {string[]}
79
+ */
80
+ function tailLines(rawOutput, n = TAIL_LINES) {
81
+ const lines = stripAnsi(rawOutput || '')
82
+ .split(/\r\n|\r|\n/)
83
+ .map(l => l.replace(/\s+$/, ''))
84
+ .filter(l => l.trim() !== '');
85
+ return lines.slice(-n);
86
+ }
87
+
88
+ /**
89
+ * Builds an error message for an abnormal validator run that is understandable
90
+ * without --verbose: exit code, the last lines of output and the log file path.
91
+ *
92
+ * @param {object} p
93
+ * @param {string} p.reason - What went wrong (e.g. "produced no result summary")
94
+ * @param {number|null} [p.code] - Exit code (null: unknown / still running)
95
+ * @param {string|null} [p.signal] - Terminating signal
96
+ * @param {string} [p.rawOutput]
97
+ * @param {string|null} [p.logPath]
98
+ * @param {string} [p.subject] - Who failed (default "FHIR Java Validator")
99
+ * @param {number} [p.tail]
100
+ * @returns {string}
101
+ */
102
+ function formatValidatorError({ reason, code = null, signal = null, rawOutput = '', logPath = null, subject = 'FHIR Java Validator', tail = TAIL_LINES }) {
103
+ let exit = '';
104
+ if (signal) exit = `was terminated by signal ${signal} and `;
105
+ else if (code !== null && code !== undefined) exit = `exited with code ${code} and `;
106
+ const parts = [`${subject} ${exit}${reason}.`];
107
+ const lines = tailLines(rawOutput, tail);
108
+ if (lines.length > 0) {
109
+ parts.push(`Last ${lines.length} line(s) of validator output:\n${lines.map(l => ` ${l}`).join('\n')}`);
110
+ } else {
111
+ parts.push('The validator produced no output.');
112
+ }
113
+ if (logPath) parts.push(`Full validator output: ${logPath}`);
114
+ return parts.join('\n');
115
+ }
116
+
117
+ /**
118
+ * One-line form of formatValidatorError (no output tail), for places where the
119
+ * message is repeated per file or embedded in a sentence: per-example errors in
120
+ * the --json output and in Markdown reports.
121
+ *
122
+ * @param {object} p - Same fields as formatValidatorError (rawOutput/tail are ignored)
123
+ * @returns {string}
124
+ */
125
+ function formatValidatorErrorShort({ reason, code = null, signal = null, logPath = null, subject = 'FHIR Java Validator' }) {
126
+ let exit = '';
127
+ if (signal) exit = ` (terminated by signal ${signal})`;
128
+ else if (code !== null && code !== undefined) exit = ` (exit code ${code})`;
129
+ return `${subject} ${reason}${exit}.${logPath ? ` Full validator output: ${logPath}` : ''}`;
130
+ }
131
+
132
+ /**
133
+ * Creates an Error whose message is the full formatValidatorError text and
134
+ * whose `shortMessage` is the one-line form. `exitCode`, `signal` and `logPath`
135
+ * are attached as well.
136
+ *
137
+ * @param {object} p - Fields of formatValidatorError
138
+ * @param {ErrorOptions} [errorOptions]
139
+ * @returns {Error & { shortMessage: string, exitCode: number|null, signal: string|null, logPath: string|null }}
140
+ */
141
+ function createValidatorError(p, errorOptions) {
142
+ const err = new Error(formatValidatorError(p), errorOptions);
143
+ err.shortMessage = formatValidatorErrorShort(p);
144
+ err.exitCode = p.code ?? null;
145
+ err.signal = p.signal ?? null;
146
+ err.logPath = p.logPath ?? null;
147
+ return err;
148
+ }
149
+
150
+ /**
151
+ * Returns a one-line description of an error: its `shortMessage` if present,
152
+ * otherwise the first line of its message (plus the "Full validator output"
153
+ * line, if the message has one).
154
+ *
155
+ * @param {Error|string} err
156
+ * @returns {string}
157
+ */
158
+ function oneLineError(err) {
159
+ if (err && typeof err === 'object' && err.shortMessage) return err.shortMessage;
160
+ const msg = String(err && typeof err === 'object' ? err.message : err);
161
+ const lines = msg.split(/\r\n|\r|\n/);
162
+ if (lines.length === 1) return msg;
163
+ const logLine = lines.find(l => /^Full validator output: /.test(l));
164
+ return logLine ? `${lines[0]} ${logLine}` : lines[0];
165
+ }
166
+
167
+ /**
168
+ * Creates a handler for validator output chunks. It collects the full output,
169
+ * mirrors it to `write` only when `verbose` is set, and reports phase changes
170
+ * (detected line by line, robust against chunks that split lines).
171
+ *
172
+ * @param {object} [opts]
173
+ * @param {boolean} [opts.verbose] - Mirror every chunk to `write`
174
+ * @param {Function} [opts.write] - Sink for mirrored output (default: stderr, gray)
175
+ * @param {Function} [opts.onPhase] - Called with the phase label when the phase changes
176
+ * @param {Function} [opts.onChunk] - Called with every raw chunk (e.g. to append to a log file)
177
+ * @param {number} [opts.maxChars] - Keep only (roughly) the last maxChars characters in memory
178
+ * (for long-running processes; the log file has everything)
179
+ * @returns {{ onData: Function, flush: Function, getOutput: Function, getPhase: Function }}
180
+ */
181
+ function createOutputHandler({ verbose = false, write, onPhase, onChunk, maxChars = 0 } = {}) {
182
+ const sink = write || ((text) => process.stderr.write(chalk.gray(text)));
183
+ const chunks = [];
184
+ let buffered = 0;
185
+ let partial = '';
186
+ let phaseIdx = -1;
187
+
188
+ const handleLine = (line) => {
189
+ const idx = detectPhase(line);
190
+ if (idx < 0 || idx === phaseIdx) return;
191
+ if (phaseIdx >= 0 && PHASES[phaseIdx].final) return;
192
+ phaseIdx = idx;
193
+ if (onPhase) onPhase(PHASES[idx].label);
194
+ };
195
+
196
+ const onData = (data) => {
197
+ const text = data.toString();
198
+ chunks.push(text);
199
+ buffered += text.length;
200
+ while (maxChars > 0 && buffered > maxChars && chunks.length > 1) {
201
+ buffered -= chunks.shift().length;
202
+ }
203
+ if (onChunk) onChunk(text);
204
+ if (verbose) sink(text);
205
+ const lines = (partial + text).split(/\r\n|\r|\n/);
206
+ partial = lines.pop();
207
+ for (const line of lines) handleLine(line);
208
+ };
209
+
210
+ const flush = () => {
211
+ if (partial) handleLine(partial);
212
+ partial = '';
213
+ };
214
+
215
+ return {
216
+ onData,
217
+ flush,
218
+ getOutput: () => chunks.join(''),
219
+ getPhase: () => (phaseIdx >= 0 ? PHASES[phaseIdx].label : null),
220
+ };
221
+ }
222
+
223
+ /**
224
+ * Concise progress indicator for a validator run on stderr.
225
+ *
226
+ * - Default, TTY: an ora spinner whose text shows the current phase.
227
+ * - Default, non-TTY (CI, piped stderr): ora prints no frames; phase changes are
228
+ * written as single lines instead, so the log stays short and readable.
229
+ * - Verbose: no spinner (it would garble the streamed output); start and end are
230
+ * printed as plain lines.
231
+ *
232
+ * @param {object} opts
233
+ * @param {string} opts.text - Base text, e.g. "Running HL7 FHIR Validator"
234
+ * @param {boolean} [opts.verbose]
235
+ * @param {NodeJS.WritableStream} [opts.stream] - default process.stderr
236
+ * @param {Function} [opts.createSpinner] - injectable ora factory (tests)
237
+ */
238
+ function createProgress({ text, verbose = false, stream = process.stderr, createSpinner } = {}) {
239
+ const write = (s) => stream.write(s);
240
+ if (verbose) {
241
+ write(chalk.cyan(`${text}...\n`));
242
+ return {
243
+ phase() {},
244
+ succeed(msg) { write(chalk.green(`\n✔ ${msg}\n`)); },
245
+ fail(msg) { write(chalk.red(`\n✖ ${msg}\n`)); },
246
+ };
247
+ }
248
+
249
+ const spinner = (createSpinner || ((o) => ora(o)))({ text: `${text}...`, stream }).start();
250
+ let lastPhase = null;
251
+ return {
252
+ phase(label) {
253
+ if (!label || label === lastPhase) return;
254
+ lastPhase = label;
255
+ if (spinner.isEnabled) spinner.text = `${text}: ${label}...`;
256
+ else write(chalk.gray(` ${label}...\n`));
257
+ },
258
+ succeed(msg) { spinner.succeed(msg); },
259
+ fail(msg) { spinner.fail(msg); },
260
+ };
261
+ }
262
+
263
+ module.exports = {
264
+ TAIL_LINES,
265
+ PHASES,
266
+ stripAnsi,
267
+ detectPhase,
268
+ extractResultInfo,
269
+ tailLines,
270
+ formatValidatorError,
271
+ formatValidatorErrorShort,
272
+ createValidatorError,
273
+ oneLineError,
274
+ createOutputHandler,
275
+ createProgress,
276
+ };