@gefyra/complies-with-eu 1.2.0 → 1.3.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.
@@ -7,6 +7,12 @@ const chalk = require('chalk');
7
7
  const { getValidatorJar, loadValidatorParams } = require('./config');
8
8
  const { getJavaCommand } = require('./javaCheck');
9
9
  const { track } = require('./processRegistry');
10
+ const {
11
+ createOutputHandler,
12
+ createProgress,
13
+ extractResultInfo,
14
+ createValidatorError,
15
+ } = require('./validatorOutput');
10
16
 
11
17
  /**
12
18
  * Runs `fn` over `items` with at most `limit` concurrent calls and returns
@@ -29,12 +35,18 @@ async function mapSettled(items, limit, fn) {
29
35
  return results;
30
36
  }
31
37
 
38
+ // "*FAILURE*: ...", "*SUCCESS*: ..." or "Success: ..." (validator 7.x prints the
39
+ // success summary without asterisks). Anchored at column 0, so issue lines
40
+ // (indented) never match.
41
+ const SUMMARY_LINE = /^\*?(FAILURE|SUCCESS)\*?:\s*(.+)/i;
42
+
32
43
  /**
33
44
  * Parses the Java Validator console output into structured issues.
34
45
  *
35
46
  * Expected line formats:
36
47
  * *FAILURE*: 6 errors, 18 warnings, 12 notes
37
- * *SUCCESS*: ...
48
+ * *SUCCESS*: ... (older validators)
49
+ * Success: 0 errors, ... (validator 7.x when there are no errors)
38
50
  * Severity @ Location (line X, colY): Message {MSG_ID}
39
51
  * Severity @ Location: Message {MSG_ID}
40
52
  * sub-detail line
@@ -50,9 +62,9 @@ function parseValidatorOutput(output) {
50
62
 
51
63
  for (const line of lines) {
52
64
  // Summary line
53
- const summaryMatch = line.match(/^\*(FAILURE|SUCCESS)\*:\s*(.+)/);
65
+ const summaryMatch = line.match(SUMMARY_LINE);
54
66
  if (summaryMatch) {
55
- summary = { result: summaryMatch[1], text: summaryMatch[2].trim() };
67
+ summary = { result: summaryMatch[1].toUpperCase(), text: summaryMatch[2].trim() };
56
68
  currentIssue = null;
57
69
  continue;
58
70
  }
@@ -163,23 +175,39 @@ function parseValidatorOutputBatch(output) {
163
175
  }
164
176
 
165
177
  /**
166
- * Spawns the Java validator and collects its combined output. All child output
167
- * is mirrored to stderr (never stdout, which is reserved for --json output).
178
+ * Normalises the legacy boolean `debug` argument or an options object into
179
+ * { debug, verbose }. --debug implies verbose output.
180
+ */
181
+ function normalizeRunOptions(opts) {
182
+ const o = (opts && typeof opts === 'object') ? opts : { debug: !!opts };
183
+ const debug = !!o.debug;
184
+ return { ...o, debug, verbose: debug || !!o.verbose };
185
+ }
186
+
187
+ /**
188
+ * Spawns the Java validator and collects its combined output. The output is
189
+ * mirrored only with `verbose` (to stderr, never to stdout, which is reserved
190
+ * for --json output); otherwise only phase hints are reported via `onPhase`.
168
191
  *
169
- * @returns {Promise<{ code: number|null, rawOutput: string }>}
192
+ * @param {string[]} args
193
+ * @param {object} [opts]
194
+ * @param {boolean} [opts.verbose] - Stream the raw output
195
+ * @param {Function} [opts.onPhase] - Phase hint callback (see validatorOutput.createOutputHandler)
196
+ * @param {Function} [opts.onChunk] - Called with every raw output chunk
197
+ * @param {Function} [opts.write] - Sink for mirrored output (default: stderr)
198
+ * @param {Function} [opts.spawnFn] - Replacement for child_process.spawn (tests)
199
+ * @returns {Promise<{ code: number|null, signal: string|null, rawOutput: string }>}
170
200
  */
171
- function spawnValidator(args) {
201
+ function spawnValidator(args, { verbose = false, onPhase, onChunk, write, spawnFn = spawn } = {}) {
172
202
  return new Promise((resolve, reject) => {
173
- const outputChunks = [];
174
- const proc = track(spawn(getJavaCommand(), args, { windowsHide: true }));
175
- const onData = (data) => {
176
- const text = data.toString();
177
- process.stderr.write(chalk.gray(text));
178
- outputChunks.push(text);
179
- };
180
- proc.stdout.on('data', onData);
181
- proc.stderr.on('data', onData);
182
- proc.on('close', (code) => resolve({ code, rawOutput: outputChunks.join('') }));
203
+ const handler = createOutputHandler({ verbose, write, onPhase, onChunk });
204
+ const proc = track(spawnFn(getJavaCommand(), args, { windowsHide: true }));
205
+ proc.stdout.on('data', handler.onData);
206
+ proc.stderr.on('data', handler.onData);
207
+ proc.on('close', (code, signal) => {
208
+ handler.flush();
209
+ resolve({ code, signal: signal || null, rawOutput: handler.getOutput() });
210
+ });
183
211
  proc.on('error', (err) => reject(new Error(`Could not start Java for the FHIR validator: ${err.message}`)));
184
212
  });
185
213
  }
@@ -202,72 +230,125 @@ function printCommand(args, debug, label, igCount) {
202
230
  }
203
231
  }
204
232
 
233
+ /**
234
+ * Runs the validator with concise progress (or streamed output with `verbose`),
235
+ * always writes the raw output to `logPath`, and rejects with an understandable
236
+ * error (exit code, output tail, log path) when `hasResult` says the output
237
+ * contains no usable result.
238
+ *
239
+ * @param {object} p
240
+ * @param {string[]} p.args
241
+ * @param {string} p.logPath
242
+ * @param {Function} p.hasResult - (rawOutput) => boolean
243
+ * @param {boolean} [p.batch] - Describe the result as a number of file results
244
+ * @param {boolean} [p.verbose]
245
+ * @param {NodeJS.WritableStream} [p.stream] - Progress/output stream (default stderr)
246
+ * @param {Function} [p.spawnFn] - Replacement for child_process.spawn (tests)
247
+ * @returns {Promise<{ code: number|null, rawOutput: string, logPath: string }>}
248
+ */
249
+ async function executeValidator({ args, logPath, hasResult, batch = false, verbose = false, stream = process.stderr, spawnFn }) {
250
+ const progress = createProgress({ text: 'Running HL7 FHIR Validator', verbose, stream });
251
+ let result;
252
+ try {
253
+ result = await spawnValidator(args, {
254
+ verbose,
255
+ spawnFn,
256
+ write: (text) => stream.write(chalk.gray(text)),
257
+ onPhase: (label) => progress.phase(label),
258
+ });
259
+ } catch (err) {
260
+ progress.fail('HL7 FHIR Validator could not be started');
261
+ throw err;
262
+ }
263
+
264
+ const { code, signal, rawOutput } = result;
265
+ // Written after the run (not streamed): in batch mode the log lives inside the
266
+ // directory being validated and must not exist while the validator scans it.
267
+ let logWritten = true;
268
+ try {
269
+ await fs.outputFile(logPath, rawOutput, 'utf8');
270
+ } catch {
271
+ logWritten = false; // best effort; never fail the run because of the log
272
+ }
273
+ const shownLog = logWritten ? logPath : null;
274
+ if (!hasResult(rawOutput)) {
275
+ progress.fail(signal
276
+ ? `HL7 FHIR Validator was terminated (${signal})`
277
+ : `HL7 FHIR Validator failed (exit code ${code}, no result summary)`);
278
+ throw createValidatorError({ reason: 'produced no result summary', code, signal, rawOutput, logPath: shownLog });
279
+ }
280
+
281
+ const { summaryLine, doneLine, summaryCount } = extractResultInfo(rawOutput);
282
+ progress.succeed(batch
283
+ ? `HL7 FHIR Validator finished: ${summaryCount} file result(s)`
284
+ : `HL7 FHIR Validator finished: ${summaryLine}`);
285
+ if (doneLine) stream.write(chalk.gray(` ${doneLine}\n`));
286
+ if (shownLog) stream.write(chalk.gray(` Raw validator output: ${shownLog}\n`));
287
+ return { code, rawOutput, logPath: shownLog };
288
+ }
289
+
205
290
  /**
206
291
  * Runs the FHIR Java Validator against a resource file.
207
292
  * Does NOT use -output / -html-output; parses the console output instead.
293
+ * The raw output is always written to "<resource>-raw.log" next to the file.
208
294
  *
209
295
  * Rejects when the validator produced no result summary (crash, wrong Java,
210
296
  * unknown option, ...): a missing summary must never be read as "no issues".
211
297
  *
212
298
  * @param {string} resourceFile - Path to the resource/profile JSON to validate
213
299
  * @param {string[]} igIds - List of name#version identifiers for -ig
214
- * @param {boolean} debug - Print the full command before execution
300
+ * @param {boolean|{debug?: boolean, verbose?: boolean}} [opts] - Legacy debug flag or options
215
301
  * @param {string[]} extraArgs - Additional arguments appended after base params (e.g. ['-resolve-path', dir])
216
- * @returns {Promise<{rawOutput: string, parsed: object, mode: 'local'}>}
302
+ * @returns {Promise<{rawOutput: string, parsed: object, mode: 'local', logPath: string}>}
217
303
  */
218
- async function runValidator(resourceFile, igIds, debug = false, extraArgs = []) {
219
-
304
+ async function runValidator(resourceFile, igIds, opts = false, extraArgs = []) {
305
+ const { debug, verbose } = normalizeRunOptions(opts);
220
306
  const args = await buildLocalArgs(resourceFile, igIds, extraArgs);
221
307
  printCommand(args, debug, '', igIds.length);
222
308
 
223
- const { code, rawOutput } = await spawnValidator(args);
224
- const parsed = parseValidatorOutput(rawOutput);
225
309
  const logPath = resourceFile.replace(/\.json$/, '') + '-raw.log';
226
- if (debug || !parsed.summary) {
227
- await fs.outputFile(logPath, rawOutput, 'utf8');
228
- if (debug) process.stderr.write(chalk.bold.yellow(`\n[DEBUG] Raw validator output written to: ${logPath}\n`));
229
- }
230
- if (!parsed.summary) {
231
- throw new Error(
232
- `FHIR Java Validator exited with code ${code} and produced no result summary. ` +
233
- `Raw output: ${logPath}`
234
- );
235
- }
236
- return { rawOutput, parsed, mode: 'local' };
310
+ const { rawOutput } = await executeValidator({
311
+ args,
312
+ logPath,
313
+ verbose,
314
+ hasResult: (out) => !!parseValidatorOutput(out).summary,
315
+ });
316
+ return { rawOutput, parsed: parseValidatorOutput(rawOutput), mode: 'local', logPath };
237
317
  }
238
318
 
239
319
  /**
240
320
  * Runs the FHIR Java Validator against an entire directory in one invocation.
241
- * Parses per-file sections from the combined output.
321
+ * Parses per-file sections from the combined output. The raw output is always
322
+ * written to "<dir>/_batch-raw.log".
242
323
  *
243
324
  * @param {string} dirPath - Directory containing the resource files to validate
244
325
  * @param {string[]} igIds - List of name#version identifiers for -ig
245
- * @param {boolean} debug - Print the full command and write raw log
326
+ * @param {boolean|{debug?: boolean, verbose?: boolean}} [opts] - Legacy debug flag or options
246
327
  * @param {string[]} extraArgs - Additional arguments appended after base params
247
- * @returns {Promise<{rawOutput: string, results: Array<{fileName, summary, issues}>}>}
328
+ * @returns {Promise<{rawOutput: string, results: Array<{fileName, summary, issues}>, logPath: string}>}
248
329
  */
249
- async function runValidatorBatch(dirPath, igIds, debug = false, extraArgs = []) {
250
-
330
+ async function runValidatorBatch(dirPath, igIds, opts = false, extraArgs = []) {
331
+ const { debug, verbose } = normalizeRunOptions(opts);
251
332
  const args = await buildLocalArgs(dirPath, igIds, extraArgs);
252
333
  printCommand(args, debug, ' (batch)', igIds.length);
253
334
 
254
- const { code, rawOutput } = await spawnValidator(args);
255
- const results = parseValidatorOutputBatch(rawOutput);
256
335
  const logPath = path.join(dirPath, '_batch-raw.log');
257
- const noResults = !results.some(r => r.summary);
258
- if (debug || noResults) {
259
- await fs.outputFile(logPath, rawOutput, 'utf8');
260
- if (debug) process.stderr.write(chalk.bold.yellow(`\n[DEBUG] Raw validator output written to: ${logPath}\n`));
261
- }
262
- if (noResults) {
263
- throw new Error(`FHIR Java Validator exited with code ${code} and produced no result summary. Raw output: ${logPath}`);
264
- }
265
- return { rawOutput, results };
336
+ const { rawOutput } = await executeValidator({
337
+ args,
338
+ logPath,
339
+ verbose,
340
+ batch: true,
341
+ hasResult: (out) => parseValidatorOutputBatch(out).some(r => r.summary),
342
+ });
343
+ return { rawOutput, results: parseValidatorOutputBatch(rawOutput), logPath };
266
344
  }
267
345
 
268
346
  module.exports = {
269
347
  runValidator,
270
348
  runValidatorBatch,
349
+ executeValidator,
350
+ spawnValidator,
351
+ normalizeRunOptions,
271
352
  parseValidatorOutput,
272
353
  parseValidatorOutputBatch,
273
354
  mapSettled,
@@ -10,12 +10,15 @@ const { getValidatorJar, loadValidatorParams } = require('./config');
10
10
  const { getJavaCommand } = require('./javaCheck');
11
11
  const { track } = require('./processRegistry');
12
12
  const { mapSettled } = require('./javaValidator');
13
+ const { createOutputHandler, createProgress, formatValidatorError, formatValidatorErrorShort, createValidatorError, tailLines } = require('./validatorOutput');
13
14
 
14
15
  const DEFAULT_PORT = 7272;
15
16
  const READY_POLL_INTERVAL_MS = 2500;
16
17
  // Total budget: port open + lazy IG loading + possible network timeouts on first request
17
18
  const READY_TIMEOUT_MS = 600_000; // 10 min
18
19
  const POST_CONCURRENCY = 4;
20
+ // In-memory tail of the server output kept for error messages (the full output goes to the log file)
21
+ const SERVER_OUTPUT_TAIL_CHARS = 64 * 1024;
19
22
  // Text of the validator message SD_EXTENSION_COMPLIES_WITH_ERROR
20
23
  const COMPLIES_WITH_ERROR_TEXT = /does not comply with claimed profile '[^']+'/;
21
24
 
@@ -70,11 +73,11 @@ async function serverHasCanonical(port, canonical) {
70
73
  * Phase 1: polls until the port accepts connections.
71
74
  * Throws if `isDead()` returns true (process exited).
72
75
  */
73
- async function waitForPort(port, deadline, isDead) {
76
+ async function waitForPort(port, deadline, isDead, pollIntervalMs = READY_POLL_INTERVAL_MS) {
74
77
  while (Date.now() < deadline) {
75
78
  if (isDead()) throw new Error('Validator process exited before the server became ready');
76
79
  if (await isServerReady(port)) return;
77
- await new Promise(r => setTimeout(r, READY_POLL_INTERVAL_MS));
80
+ await new Promise(r => setTimeout(r, Math.max(0, Math.min(pollIntervalMs, deadline - Date.now()))));
78
81
  }
79
82
  throw new Error(`Validator server on port ${port} did not start within the timeout`);
80
83
  }
@@ -115,46 +118,98 @@ async function warmupServer(port, deadline, isDead) {
115
118
  * Waits until the server is truly ready (lazy loading complete) before returning.
116
119
  * The JVM is killed if startup or warmup fails, and when the CLI exits.
117
120
  *
121
+ * The server output is written to `logFile` (when given) and streamed to stderr
122
+ * only with `verbose`/`debug`; otherwise a concise progress indicator is shown.
123
+ * Startup failures include the last lines of server output and the log path.
124
+ *
118
125
  * @param {object} opts
119
- * @param {string[]} opts.igIds - IG identifiers (name#version or local path) to pre-load
120
- * @param {number} [opts.port] - Port to listen on (default: a free port chosen by the OS)
121
- * @param {boolean} [opts.debug] - Print the full startup command
122
- * @returns {Promise<{ port: number, stop: Function }>}
126
+ * @param {string[]} opts.igIds - IG identifiers (name#version or local path) to pre-load
127
+ * @param {number} [opts.port] - Port to listen on (default: a free port chosen by the OS)
128
+ * @param {boolean} [opts.debug] - Print the full startup command (implies verbose)
129
+ * @param {boolean} [opts.verbose] - Stream the full server output to stderr
130
+ * @param {string} [opts.logFile] - File that receives the raw server output
131
+ * @param {NodeJS.WritableStream} [opts.stream] - Progress / output sink (default process.stderr)
132
+ * @param {Function} [opts.spawnFn] - child_process.spawn replacement (tests)
133
+ * @param {string} [opts.jar] - validator_cli.jar path (default: the installed one)
134
+ * @param {number} [opts.readyTimeoutMs] - Startup + warmup budget (default 10 min)
135
+ * @param {number} [opts.pollIntervalMs] - Port poll interval
136
+ * @param {number} [opts.startupGraceMs] - Pause that catches immediate start failures
137
+ * @returns {Promise<{ port: number, stop: Function, logFile: string|null,
138
+ * isDead: Function, exitInfo: Function, getOutput: Function, tail: Function }>}
139
+ * `isDead()` / `exitInfo()` tell whether (and how) the JVM exited after startup,
140
+ * `getOutput()` returns the in-memory tail of its output, `tail(n)` its last n lines.
123
141
  */
124
- async function startValidatorServer({ igIds, port = null, debug = false }) {
125
- const jar = getValidatorJar();
142
+ async function startValidatorServer({
143
+ igIds,
144
+ port = null,
145
+ debug = false,
146
+ verbose = false,
147
+ logFile = null,
148
+ stream = process.stderr,
149
+ spawnFn = spawn,
150
+ jar = getValidatorJar(),
151
+ readyTimeoutMs = READY_TIMEOUT_MS,
152
+ pollIntervalMs = READY_POLL_INTERVAL_MS,
153
+ startupGraceMs = 600,
154
+ }) {
126
155
  if (!await fs.pathExists(jar)) {
127
156
  throw new Error(`validator_cli.jar not found at ${jar}. Run: complies-with-eu init`);
128
157
  }
129
158
  const listenPort = port || await findFreePort();
159
+ const showOutput = debug || verbose;
130
160
 
131
161
  const baseParams = loadValidatorParams();
132
162
  const igArgs = igIds.flatMap(id => ['-ig', id]);
133
163
 
134
164
  const args = [
135
165
  '-jar', jar,
136
- '-server', String(listenPort),
166
+ 'server', String(listenPort),
137
167
  ...igArgs,
138
168
  ...baseParams,
139
169
  ];
140
170
 
171
+ const write = (text) => stream.write(text);
141
172
  if (debug) {
142
- process.stderr.write(chalk.bold.yellow('\n[DEBUG] Starting validator server:\n'));
143
- process.stderr.write(chalk.yellow(`${getJavaCommand()} ${args.join(' ')}\n\n`));
173
+ write(chalk.bold.yellow('\n[DEBUG] Starting validator server:\n'));
174
+ write(chalk.yellow(`${getJavaCommand()} ${args.join(' ')}\n\n`));
144
175
  } else {
145
- process.stderr.write(chalk.gray(` java -jar validator_cli.jar -server ${listenPort} ... (${igIds.length} IGs)\n`));
176
+ write(chalk.gray(` java -jar validator_cli.jar server ${listenPort} ... (${igIds.length} IGs)\n`));
177
+ }
178
+
179
+ let logStream = null;
180
+ if (logFile) {
181
+ await fs.ensureDir(path.dirname(logFile));
182
+ logStream = fs.createWriteStream(logFile, { encoding: 'utf8' });
183
+ logStream.on('error', () => { /* the log is best effort */ });
146
184
  }
147
185
 
148
- const proc = track(spawn(getJavaCommand(), args, { windowsHide: true }));
186
+ const progress = createProgress({ text: 'Starting HL7 FHIR Validator server', verbose: showOutput, stream });
187
+ let starting = true;
188
+ const handler = createOutputHandler({
189
+ verbose: showOutput,
190
+ write: (text) => write(chalk.gray(text)),
191
+ maxChars: SERVER_OUTPUT_TAIL_CHARS,
192
+ onPhase: (label) => { if (starting) progress.phase(label); },
193
+ onChunk: (text) => { if (logStream) logStream.write(text); },
194
+ });
149
195
 
150
- proc.stdout.on('data', d => process.stderr.write(chalk.gray(d.toString())));
151
- proc.stderr.on('data', d => process.stderr.write(chalk.gray(d.toString())));
196
+ const proc = track(spawnFn(getJavaCommand(), args, { windowsHide: true }));
197
+ proc.stdout.on('data', handler.onData);
198
+ proc.stderr.on('data', handler.onData);
152
199
 
153
200
  let procExited = false;
154
201
  let procError = null;
202
+ let exitCode = null;
203
+ let exitSignal = null;
155
204
  proc.on('error', err => { procError = err; });
156
- proc.on('exit', () => { procExited = true; });
205
+ proc.on('exit', (code, signal) => {
206
+ procExited = true;
207
+ exitCode = code;
208
+ exitSignal = signal || null;
209
+ });
210
+ proc.on('close', () => { if (logStream) logStream.end(); });
157
211
 
212
+ const isDead = () => procExited || !!procError;
158
213
  const stop = () => {
159
214
  if (!procExited) {
160
215
  try { proc.kill(); } catch { /* ignore */ }
@@ -163,22 +218,109 @@ async function startValidatorServer({ igIds, port = null, debug = false }) {
163
218
 
164
219
  try {
165
220
  // Brief pause to catch immediate startup failures (bad jar path, wrong Java, etc.)
166
- await new Promise(r => setTimeout(r, 600));
221
+ await new Promise(r => setTimeout(r, startupGraceMs));
167
222
  if (procError) throw new Error(`Failed to start validator server: ${procError.message}`);
168
223
 
169
- const deadline = Date.now() + READY_TIMEOUT_MS;
170
- const isDead = () => procExited || !!procError;
224
+ const deadline = Date.now() + readyTimeoutMs;
171
225
 
172
- await waitForPort(listenPort, deadline, isDead);
173
- process.stderr.write(chalk.gray(` Validator server accepting connections on port ${listenPort}, warming up...\n`));
226
+ await waitForPort(listenPort, deadline, isDead, pollIntervalMs);
227
+ if (showOutput) {
228
+ write(chalk.gray(` Validator server accepting connections on port ${listenPort}, warming up...\n`));
229
+ } else {
230
+ progress.phase('Warming up');
231
+ }
174
232
 
175
233
  await warmupServer(listenPort, deadline, isDead);
176
234
  } catch (err) {
235
+ starting = false;
177
236
  stop();
178
- throw err;
237
+ progress.fail('HL7 FHIR Validator server could not be started');
238
+ if (procError) throw err;
239
+ handler.flush();
240
+ throw createValidatorError({
241
+ subject: 'FHIR Java Validator server',
242
+ reason: `did not become ready (${err.message})`,
243
+ code: procExited ? exitCode : null,
244
+ signal: exitSignal,
245
+ rawOutput: handler.getOutput(),
246
+ logPath: logFile,
247
+ }, { cause: err });
248
+ }
249
+
250
+ starting = false;
251
+ progress.succeed(`HL7 FHIR Validator server ready on port ${listenPort}`);
252
+ if (logFile) write(chalk.gray(` Server output: ${logFile}\n`));
253
+ return {
254
+ port: listenPort,
255
+ stop,
256
+ logFile,
257
+ isDead,
258
+ exitInfo: () => ({
259
+ exited: isDead(),
260
+ code: procExited ? exitCode : null,
261
+ signal: exitSignal,
262
+ error: procError ? procError.message : null,
263
+ }),
264
+ getOutput: () => { handler.flush(); return handler.getOutput(); },
265
+ tail: (n) => tailLines(handler.getOutput(), n),
266
+ };
267
+ }
268
+
269
+ /**
270
+ * Checks the results of a server batch for failed requests and, if there are
271
+ * any, explains them, so that a crashed or hung server is understandable
272
+ * without --verbose. A short grace period lets the JVM exit be noticed when the
273
+ * requests failed because it has just died.
274
+ *
275
+ * @param {object} server - Return value of startValidatorServer, or a reused
276
+ * server `{ port, stop }` without process information
277
+ * @param {Array<{error?: string}>} results - validateBatchViaServer results
278
+ * @param {object} [opts]
279
+ * @param {number} [opts.exitGraceMs] - How long to wait for a pending exit
280
+ * @returns {Promise<null|{ failed: number, total: number, dead: boolean, message: string, short: string }>}
281
+ * null when every request got a result. `message` is the full explanation
282
+ * (exit code, output tail, log path) for the console; `short` is one line for
283
+ * per-profile errors, the JSON output and reports.
284
+ */
285
+ async function describeBatchFailure(server, results, { exitGraceMs = 1000 } = {}) {
286
+ const total = results.length;
287
+ const failed = results.filter(r => r && r.error).length;
288
+ if (failed === 0) return null;
289
+
290
+ const canInspect = typeof server.isDead === 'function';
291
+ if (canInspect && !server.isDead() && exitGraceMs > 0) {
292
+ const until = Date.now() + exitGraceMs;
293
+ while (!server.isDead() && Date.now() < until) await new Promise(r => setTimeout(r, 50));
179
294
  }
295
+ const dead = canInspect && server.isDead();
296
+ const info = canInspect ? server.exitInfo() : { code: null, signal: null };
297
+ const logPath = server.logFile || null;
298
+
299
+ let reason;
300
+ if (dead) reason = 'did not finish the batch validation';
301
+ else if (failed === total) reason = `returned no result for any of the ${total} profile(s)`;
302
+ else reason = `returned no result for ${failed} of ${total} profile(s)`;
303
+
304
+ const message = canInspect
305
+ ? formatValidatorError({
306
+ subject: 'FHIR Java Validator server',
307
+ reason,
308
+ code: info.code,
309
+ signal: info.signal,
310
+ rawOutput: server.getOutput(),
311
+ logPath,
312
+ })
313
+ : `FHIR Java Validator server ${reason}.`;
314
+
315
+ const short = formatValidatorErrorShort({
316
+ subject: 'FHIR Java Validator server',
317
+ reason,
318
+ code: dead ? info.code : null,
319
+ signal: dead ? info.signal : null,
320
+ logPath,
321
+ });
180
322
 
181
- return { port: listenPort, stop };
323
+ return { failed, total, dead, message, short };
182
324
  }
183
325
 
184
326
  /**
@@ -313,4 +455,5 @@ module.exports = {
313
455
  parseOperationOutcome,
314
456
  validateViaServer,
315
457
  validateBatchViaServer,
458
+ describeBatchFailure,
316
459
  };
@@ -0,0 +1,44 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Output streams of the CLI.
5
+ *
6
+ * - Progress (status lines, lists, spinners from ora) always goes to stderr, so
7
+ * all human progress output shares one stream and keeps its order when stdout
8
+ * and stderr are captured separately (e.g. Docker logs, CI) or merged.
9
+ * - The final result (e.g. the PASS/FAIL summary and the report path) goes to
10
+ * stdout, unless --json is set: then stdout carries only the JSON document and
11
+ * the human result goes to stderr as well.
12
+ */
13
+
14
+ // eslint-disable-next-line no-control-regex
15
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
16
+
17
+ /**
18
+ * chalk decides about colors based on stdout. When stdout is a terminal but
19
+ * stderr is redirected, strip the color codes from stderr lines so log files
20
+ * stay readable. FORCE_COLOR keeps the colors.
21
+ */
22
+ function stripColorsForStream(stream, text) {
23
+ if (stream.isTTY || process.env.FORCE_COLOR) return text;
24
+ return text.replace(ANSI_RE, '');
25
+ }
26
+
27
+ /** Returns a console.log-like writer for the given stream (args joined with a space). */
28
+ function writerFor(stream) {
29
+ return (...args) => {
30
+ stream.write(stripColorsForStream(stream, args.join(' ')) + '\n');
31
+ };
32
+ }
33
+
34
+ /** Progress logger: always stderr (same stream as the ora spinners). */
35
+ function makeProgressLogger() {
36
+ return writerFor(process.stderr);
37
+ }
38
+
39
+ /** Result logger: stdout, or stderr with --json (stdout is reserved for the JSON document). */
40
+ function makeResultLogger(options = {}) {
41
+ return options.json ? writerFor(process.stderr) : writerFor(process.stdout);
42
+ }
43
+
44
+ module.exports = { makeProgressLogger, makeResultLogger, stripColorsForStream };
@@ -13,7 +13,7 @@ const { ensureJava, requiredJavaMajor } = require('./javaCheck');
13
13
  const { resolvePackageDir, packageHasSnapshots, downloadFhirPackage } = require('./fhirPkgTool');
14
14
  const { CliError } = require('./errors');
15
15
 
16
- const stderrLog = (...args) => process.stderr.write(args.join(' ') + '\n');
16
+ const stderrLog = require('./output').makeProgressLogger();
17
17
 
18
18
  /**
19
19
  * Lists what `init` should have installed but is missing (pure-ish; no Java needed).