@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.
- package/CHANGELOG.md +21 -0
- package/README.md +19 -10
- package/bin/complies-with-eu.js +11 -3
- package/package.json +6 -6
- package/src/commands/search.js +8 -3
- package/src/commands/validate.js +82 -45
- package/src/lib/constraintCheck.js +19 -0
- package/src/lib/javaValidator.js +122 -47
- package/src/lib/javaValidatorServer.js +166 -23
- package/src/lib/output.js +44 -0
- package/src/lib/preflight.js +1 -1
- package/src/lib/reportGen.js +39 -5
- package/src/lib/toolDownloader.js +3 -3
- package/src/lib/validateCore.js +13 -6
- package/src/lib/validatorOutput.js +276 -0
package/src/lib/javaValidator.js
CHANGED
|
@@ -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
|
-
*
|
|
167
|
-
*
|
|
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
|
-
* @
|
|
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
|
|
174
|
-
const proc = track(
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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}
|
|
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,
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
|
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,
|
|
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
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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,
|
|
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
|
|
120
|
-
* @param {number} [opts.port]
|
|
121
|
-
* @param {boolean} [opts.debug]
|
|
122
|
-
* @
|
|
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({
|
|
125
|
-
|
|
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
|
-
'
|
|
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
|
-
|
|
143
|
-
|
|
173
|
+
write(chalk.bold.yellow('\n[DEBUG] Starting validator server:\n'));
|
|
174
|
+
write(chalk.yellow(`${getJavaCommand()} ${args.join(' ')}\n\n`));
|
|
144
175
|
} else {
|
|
145
|
-
|
|
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
|
|
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
|
|
151
|
-
proc.
|
|
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', () => {
|
|
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,
|
|
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() +
|
|
170
|
-
const isDead = () => procExited || !!procError;
|
|
224
|
+
const deadline = Date.now() + readyTimeoutMs;
|
|
171
225
|
|
|
172
|
-
await waitForPort(listenPort, deadline, isDead);
|
|
173
|
-
|
|
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
|
-
|
|
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 {
|
|
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 };
|
package/src/lib/preflight.js
CHANGED
|
@@ -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 = (
|
|
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).
|
package/src/lib/reportGen.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
lines.push(`| ${mdCell(
|
|
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');
|