@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.
@@ -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
@@ -163,23 +169,39 @@ function parseValidatorOutputBatch(output) {
163
169
  }
164
170
 
165
171
  /**
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).
172
+ * Normalises the legacy boolean `debug` argument or an options object into
173
+ * { debug, verbose }. --debug implies verbose output.
174
+ */
175
+ function normalizeRunOptions(opts) {
176
+ const o = (opts && typeof opts === 'object') ? opts : { debug: !!opts };
177
+ const debug = !!o.debug;
178
+ return { ...o, debug, verbose: debug || !!o.verbose };
179
+ }
180
+
181
+ /**
182
+ * Spawns the Java validator and collects its combined output. The output is
183
+ * mirrored only with `verbose` (to stderr, never to stdout, which is reserved
184
+ * for --json output); otherwise only phase hints are reported via `onPhase`.
168
185
  *
169
- * @returns {Promise<{ code: number|null, rawOutput: string }>}
186
+ * @param {string[]} args
187
+ * @param {object} [opts]
188
+ * @param {boolean} [opts.verbose] - Stream the raw output
189
+ * @param {Function} [opts.onPhase] - Phase hint callback (see validatorOutput.createOutputHandler)
190
+ * @param {Function} [opts.onChunk] - Called with every raw output chunk
191
+ * @param {Function} [opts.write] - Sink for mirrored output (default: stderr)
192
+ * @param {Function} [opts.spawnFn] - Replacement for child_process.spawn (tests)
193
+ * @returns {Promise<{ code: number|null, signal: string|null, rawOutput: string }>}
170
194
  */
171
- function spawnValidator(args) {
195
+ function spawnValidator(args, { verbose = false, onPhase, onChunk, write, spawnFn = spawn } = {}) {
172
196
  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('') }));
197
+ const handler = createOutputHandler({ verbose, write, onPhase, onChunk });
198
+ const proc = track(spawnFn(getJavaCommand(), args, { windowsHide: true }));
199
+ proc.stdout.on('data', handler.onData);
200
+ proc.stderr.on('data', handler.onData);
201
+ proc.on('close', (code, signal) => {
202
+ handler.flush();
203
+ resolve({ code, signal: signal || null, rawOutput: handler.getOutput() });
204
+ });
183
205
  proc.on('error', (err) => reject(new Error(`Could not start Java for the FHIR validator: ${err.message}`)));
184
206
  });
185
207
  }
@@ -202,72 +224,125 @@ function printCommand(args, debug, label, igCount) {
202
224
  }
203
225
  }
204
226
 
227
+ /**
228
+ * Runs the validator with concise progress (or streamed output with `verbose`),
229
+ * always writes the raw output to `logPath`, and rejects with an understandable
230
+ * error (exit code, output tail, log path) when `hasResult` says the output
231
+ * contains no usable result.
232
+ *
233
+ * @param {object} p
234
+ * @param {string[]} p.args
235
+ * @param {string} p.logPath
236
+ * @param {Function} p.hasResult - (rawOutput) => boolean
237
+ * @param {boolean} [p.batch] - Describe the result as a number of file results
238
+ * @param {boolean} [p.verbose]
239
+ * @param {NodeJS.WritableStream} [p.stream] - Progress/output stream (default stderr)
240
+ * @param {Function} [p.spawnFn] - Replacement for child_process.spawn (tests)
241
+ * @returns {Promise<{ code: number|null, rawOutput: string, logPath: string }>}
242
+ */
243
+ async function executeValidator({ args, logPath, hasResult, batch = false, verbose = false, stream = process.stderr, spawnFn }) {
244
+ const progress = createProgress({ text: 'Running HL7 FHIR Validator', verbose, stream });
245
+ let result;
246
+ try {
247
+ result = await spawnValidator(args, {
248
+ verbose,
249
+ spawnFn,
250
+ write: (text) => stream.write(chalk.gray(text)),
251
+ onPhase: (label) => progress.phase(label),
252
+ });
253
+ } catch (err) {
254
+ progress.fail('HL7 FHIR Validator could not be started');
255
+ throw err;
256
+ }
257
+
258
+ const { code, signal, rawOutput } = result;
259
+ // Written after the run (not streamed): in batch mode the log lives inside the
260
+ // directory being validated and must not exist while the validator scans it.
261
+ let logWritten = true;
262
+ try {
263
+ await fs.outputFile(logPath, rawOutput, 'utf8');
264
+ } catch {
265
+ logWritten = false; // best effort; never fail the run because of the log
266
+ }
267
+ const shownLog = logWritten ? logPath : null;
268
+ if (!hasResult(rawOutput)) {
269
+ progress.fail(signal
270
+ ? `HL7 FHIR Validator was terminated (${signal})`
271
+ : `HL7 FHIR Validator failed (exit code ${code}, no result summary)`);
272
+ throw createValidatorError({ reason: 'produced no result summary', code, signal, rawOutput, logPath: shownLog });
273
+ }
274
+
275
+ const { summaryLine, doneLine, summaryCount } = extractResultInfo(rawOutput);
276
+ progress.succeed(batch
277
+ ? `HL7 FHIR Validator finished: ${summaryCount} file result(s)`
278
+ : `HL7 FHIR Validator finished: ${summaryLine}`);
279
+ if (doneLine) stream.write(chalk.gray(` ${doneLine}\n`));
280
+ if (shownLog) stream.write(chalk.gray(` Raw validator output: ${shownLog}\n`));
281
+ return { code, rawOutput, logPath: shownLog };
282
+ }
283
+
205
284
  /**
206
285
  * Runs the FHIR Java Validator against a resource file.
207
286
  * Does NOT use -output / -html-output; parses the console output instead.
287
+ * The raw output is always written to "<resource>-raw.log" next to the file.
208
288
  *
209
289
  * Rejects when the validator produced no result summary (crash, wrong Java,
210
290
  * unknown option, ...): a missing summary must never be read as "no issues".
211
291
  *
212
292
  * @param {string} resourceFile - Path to the resource/profile JSON to validate
213
293
  * @param {string[]} igIds - List of name#version identifiers for -ig
214
- * @param {boolean} debug - Print the full command before execution
294
+ * @param {boolean|{debug?: boolean, verbose?: boolean}} [opts] - Legacy debug flag or options
215
295
  * @param {string[]} extraArgs - Additional arguments appended after base params (e.g. ['-resolve-path', dir])
216
- * @returns {Promise<{rawOutput: string, parsed: object, mode: 'local'}>}
296
+ * @returns {Promise<{rawOutput: string, parsed: object, mode: 'local', logPath: string}>}
217
297
  */
218
- async function runValidator(resourceFile, igIds, debug = false, extraArgs = []) {
219
-
298
+ async function runValidator(resourceFile, igIds, opts = false, extraArgs = []) {
299
+ const { debug, verbose } = normalizeRunOptions(opts);
220
300
  const args = await buildLocalArgs(resourceFile, igIds, extraArgs);
221
301
  printCommand(args, debug, '', igIds.length);
222
302
 
223
- const { code, rawOutput } = await spawnValidator(args);
224
- const parsed = parseValidatorOutput(rawOutput);
225
303
  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' };
304
+ const { rawOutput } = await executeValidator({
305
+ args,
306
+ logPath,
307
+ verbose,
308
+ hasResult: (out) => !!parseValidatorOutput(out).summary,
309
+ });
310
+ return { rawOutput, parsed: parseValidatorOutput(rawOutput), mode: 'local', logPath };
237
311
  }
238
312
 
239
313
  /**
240
314
  * Runs the FHIR Java Validator against an entire directory in one invocation.
241
- * Parses per-file sections from the combined output.
315
+ * Parses per-file sections from the combined output. The raw output is always
316
+ * written to "<dir>/_batch-raw.log".
242
317
  *
243
318
  * @param {string} dirPath - Directory containing the resource files to validate
244
319
  * @param {string[]} igIds - List of name#version identifiers for -ig
245
- * @param {boolean} debug - Print the full command and write raw log
320
+ * @param {boolean|{debug?: boolean, verbose?: boolean}} [opts] - Legacy debug flag or options
246
321
  * @param {string[]} extraArgs - Additional arguments appended after base params
247
- * @returns {Promise<{rawOutput: string, results: Array<{fileName, summary, issues}>}>}
322
+ * @returns {Promise<{rawOutput: string, results: Array<{fileName, summary, issues}>, logPath: string}>}
248
323
  */
249
- async function runValidatorBatch(dirPath, igIds, debug = false, extraArgs = []) {
250
-
324
+ async function runValidatorBatch(dirPath, igIds, opts = false, extraArgs = []) {
325
+ const { debug, verbose } = normalizeRunOptions(opts);
251
326
  const args = await buildLocalArgs(dirPath, igIds, extraArgs);
252
327
  printCommand(args, debug, ' (batch)', igIds.length);
253
328
 
254
- const { code, rawOutput } = await spawnValidator(args);
255
- const results = parseValidatorOutputBatch(rawOutput);
256
329
  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 };
330
+ const { rawOutput } = await executeValidator({
331
+ args,
332
+ logPath,
333
+ verbose,
334
+ batch: true,
335
+ hasResult: (out) => parseValidatorOutputBatch(out).some(r => r.summary),
336
+ });
337
+ return { rawOutput, results: parseValidatorOutputBatch(rawOutput), logPath };
266
338
  }
267
339
 
268
340
  module.exports = {
269
341
  runValidator,
270
342
  runValidatorBatch,
343
+ executeValidator,
344
+ spawnValidator,
345
+ normalizeRunOptions,
271
346
  parseValidatorOutput,
272
347
  parseValidatorOutputBatch,
273
348
  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).
@@ -2,6 +2,7 @@
2
2
 
3
3
  const path = require('path');
4
4
  const fs = require('fs-extra');
5
+ const { findConstraintPaths } = require('./constraintCheck');
5
6
 
6
7
  /**
7
8
  * Escapes text for use in Markdown/HTML outside code spans (reports are rendered
@@ -53,8 +54,15 @@ function shortName(canonical) {
53
54
 
54
55
  /**
55
56
  * Turns a raw "slice error: ..." detail line into a table row [path, problem].
57
+ *
58
+ * The validator's "constraint ... missing" message carries no element path. When
59
+ * the claimed EU SD is passed and exactly one of its elements declares that
60
+ * constraint, its path is shown; otherwise the path stays "—".
61
+ *
62
+ * @param {string} raw
63
+ * @param {object} [euSd] - The claimed EU StructureDefinition (optional)
56
64
  */
57
- function parseSliceDetail(raw) {
65
+ function parseSliceDetail(raw, euSd = null) {
58
66
  const text = raw.replace(/^slice error:\s*/i, '').trim();
59
67
 
60
68
  const minMaxMatch = text.match(
@@ -66,7 +74,8 @@ function parseSliceDetail(raw) {
66
74
 
67
75
  const constraintMatch = text.match(/The constraint '(.+)' is defined in the claimed profile, but missing/);
68
76
  if (constraintMatch) {
69
- return ['—', `Constraint missing: ${mdCode(constraintMatch[1])}`];
77
+ const paths = euSd ? findConstraintPaths(euSd, constraintMatch[1]) : [];
78
+ return [paths.length === 1 ? mdCode(paths[0]) : '—', `Constraint missing: ${mdCode(constraintMatch[1])}`];
70
79
  }
71
80
 
72
81
  const slicingMatch = text.match(/Mismatch in slicing at (\S+):/);
@@ -77,6 +86,28 @@ function parseSliceDetail(raw) {
77
86
  return ['—', mdText(text)];
78
87
  }
79
88
 
89
+ /**
90
+ * Builds the rows of a per-EU-profile details table: identical rows (the
91
+ * validator repeats e.g. the same constraint text for several elements) are
92
+ * collapsed into one, in first-seen order, with "(reported N×)" appended.
93
+ *
94
+ * @param {string[]} details - Raw detail lines
95
+ * @param {object} [euSd] - The claimed EU StructureDefinition (optional, see parseSliceDetail)
96
+ * @returns {Array<[string, string]>} [path, problem] rows
97
+ */
98
+ function buildDetailRows(details, euSd = null) {
99
+ const rows = new Map(); // "path\0problem" → { path, problem, count }
100
+ for (const d of details) {
101
+ const [p, problem] = parseSliceDetail(d, euSd);
102
+ const key = `${p}\u0000${problem}`;
103
+ const row = rows.get(key);
104
+ if (row) row.count++;
105
+ else rows.set(key, { path: p, problem, count: 1 });
106
+ }
107
+ return [...rows.values()].map(({ path: p, problem, count }) =>
108
+ [p, count > 1 ? `${problem} (reported ${count}×)` : problem]);
109
+ }
110
+
80
111
  /**
81
112
  * Generates a Markdown compliance report from parsed validator console output.
82
113
  *
@@ -90,6 +121,7 @@ function parseSliceDetail(raw) {
90
121
  * @param {Map} [opts.obligationViolationsByEuCanonical] - Map of euCanonical → violation[]
91
122
  * @param {Map} [opts.filteredNonCompliantMap] - Pre-filtered nonCompliantMap (compliesWithProfile errors)
92
123
  * @param {Array} [opts.autoSatisfiedConstraints] - Auto-resolved constraints: [{ euCanonical, expression, basePath, matchedChild }]
124
+ * @param {Map} [opts.euSdByCanonical] - Map of EU canonical (without version) → EU SD, used to recover element paths of missing constraints
93
125
  */
94
126
  async function generateReport(opts) {
95
127
  const { canonical, packageSpec, euCanonicals, parsed, reportPath } = opts;
@@ -104,6 +136,7 @@ async function generateReport(opts) {
104
136
  return v ? `${url}|${v}` : url;
105
137
  };
106
138
  const { issues } = parsed;
139
+ const euSdByCanonical = opts.euSdByCanonical ?? new Map();
107
140
 
108
141
  // Split compliesWithProfile errors from everything else
109
142
  const cwpErrors = issues.filter(i => i.msgId === 'SD_EXTENSION_COMPLIES_WITH_ERROR');
@@ -175,9 +208,9 @@ async function generateReport(opts) {
175
208
  } else {
176
209
  lines.push('| Path | Issue |');
177
210
  lines.push('|------|-------|');
178
- for (const d of details) {
179
- const [path, problem] = parseSliceDetail(d);
180
- lines.push(`| ${mdCell(path)} | ${mdCell(problem)} |`);
211
+ const euSd = euSdByCanonical.get(c.split('|')[0]) ?? null;
212
+ for (const [rowPath, problem] of buildDetailRows(details, euSd)) {
213
+ lines.push(`| ${mdCell(rowPath)} | ${mdCell(problem)} |`);
181
214
  }
182
215
  }
183
216
  lines.push('');
@@ -429,6 +462,7 @@ module.exports = {
429
462
  generateExampleReport,
430
463
  appendExampleSummary,
431
464
  parseSliceDetail,
465
+ buildDetailRows,
432
466
  mdText,
433
467
  mdCell,
434
468
  mdCode,
@@ -77,12 +77,12 @@ async function downloadFile(url, destPath, { sha256 = null } = {}) {
77
77
  maxRedirects: 10,
78
78
  });
79
79
  } catch (err) {
80
- throw new Error(describeDownloadError(err, url));
80
+ throw new Error(describeDownloadError(err, url), { cause: err });
81
81
  }
82
82
  try {
83
83
  await pipeline(response.data, fs.createWriteStream(partPath));
84
84
  } catch (err) {
85
- throw new Error(describeDownloadError(err, url));
85
+ throw new Error(describeDownloadError(err, url), { cause: err });
86
86
  }
87
87
  if (sha256) {
88
88
  const actual = await sha256File(partPath);
@@ -109,7 +109,7 @@ async function getLatestReleaseAsset(repo, assetPattern) {
109
109
  timeout: 30_000,
110
110
  });
111
111
  } catch (err) {
112
- throw new Error(describeDownloadError(err, apiUrl));
112
+ throw new Error(describeDownloadError(err, apiUrl), { cause: err });
113
113
  }
114
114
  const assets = response.data.assets || [];
115
115
  const re = new RegExp(assetPattern, 'i');