pyEDAA.Reports.CodeCoverage.GHDL

pyEDAA/Reports/CodeCoverage/GHDL.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
# ==================================================================================================================== #
#              _____ ____    _        _      ____                       _                                              #
#  _ __  _   _| ____|  _ \  / \      / \    |  _ \ ___ _ __   ___  _ __| |_ ___                                        #
# | '_ \| | | |  _| | | | |/ _ \    / _ \   | |_) / _ \ '_ \ / _ \| '__| __/ __|                                       #
# | |_) | |_| | |___| |_| / ___ \  / ___ \ _|  _ <  __/ |_) | (_) | |  | |_\__ \                                       #
# | .__/ \__, |_____|____/_/   \_\/_/   \_(_)_| \_\___| .__/ \___/|_|   \__|___/                                       #
# |_|    |___/                                        |_|                                                              #
# ==================================================================================================================== #
# Authors:                                                                                                             #
#   Patrick Lehmann                                                                                                    #
#                                                                                                                      #
# License:                                                                                                             #
# ==================================================================================================================== #
# Copyright 2026-2026 Electronic Design Automation Abstraction (EDA²)                                                  #
#                                                                                                                      #
# Licensed under the Apache License, Version 2.0 (the "License");                                                      #
# you may not use this file except in compliance with the License.                                                     #
# You may obtain a copy of the License at                                                                              #
#                                                                                                                      #
#   http://www.apache.org/licenses/LICENSE-2.0                                                                         #
#                                                                                                                      #
# Unless required by applicable law or agreed to in writing, software                                                  #
# distributed under the License is distributed on an "AS IS" BASIS,                                                    #
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.                                             #
# See the License for the specific language governing permissions and                                                  #
# limitations under the License.                                                                                       #
#                                                                                                                      #
# SPDX-License-Identifier: Apache-2.0                                                                                  #
# ==================================================================================================================== #
#
"""
GHDL's JSON code coverage format: a model of the format, read from coverage files, merged, and converted to the common
model.

GHDL writes the format when simulating with ``ghdl -r --coverage``, by default to :file:`coverage-<timestamp>.json`. A
coverage file is validated against the JSON Schema of the format version it states, listed in :data:`SCHEMAS` - e.g.
:file:`GHDL-Coverage-1.0.0.schema.json`, reverse-engineered from GHDL. The format's model keeps what the file states: a
:class:`Document` holds :class:`File` records, each with its checksum and, per line with a coverage point, whether the
line ran.

:class:`MergedReport` merges the coverage files of several simulation runs, as ``ghdl coverage`` reads several files.
:meth:`Document.ToCoverageSummary` and :meth:`MergedReport.ToCoverageSummary` convert to the common model of
:mod:`pyEDAA.Reports.CodeCoverage`:

* A file's path is the name it was analyzed by - relative to the directory GHDL ran in -, or, if analyzed in another
  directory, prefixed by that directory.
* A line that ran is covered, a line that didn't uncovered. The format states statement coverage as a flag per line:
  it has no counts, no branches and no excluded lines.
* The format names no units: no design units, processes or subprograms.

.. rubric:: Example

.. code-block:: Python

   from pathlib import Path
   from pyEDAA.Reports.CodeCoverage.GHDL import Document, MergedReport

   documents = []
   for path in sorted(Path(".").glob("coverage-*.json")):
     documents.append(Document(path, analyzeAndConvert=True))

   summary = MergedReport("Counter", documents).ToCoverageSummary()
   print(f"{summary.FileCount} files: {summary.LineCoverage:.1%}")
"""
from __future__                  import annotations

from datetime                    import datetime, timezone
from json                        import JSONDecodeError, loads
from pathlib                     import Path
from re                          import fullmatch
from typing                      import Any, Generic, Iterable, Optional as Nullable, TypeVar

from jsonschema                  import Draft202012Validator
from pyTooling.Common            import getFullyQualifiedName, readResourceFile, StringEnum
from pyTooling.Decorators        import export, readonly
from pyTooling.Exceptions        import ToolingException
from pyTooling.MetaClasses       import ExtendedType
from pyTooling.Stopwatch         import Stopwatch

from pyEDAA.Reports              import Resources
from pyEDAA.Reports.CodeCoverage import CodeCoverageError, CoverageSummary, Document as cc_Document, Line as cc_Line
from pyEDAA.Reports.CodeCoverage import LineCoverageStatus


__all__ = ["SCHEMAS", "ParentType"]

# A class with a property named like a class - ``Path`` - can't name that class in the annotation of a field: the class
# body's namespace, where annotations are evaluated, binds the name to the property.
_Path = Path

ParentType = TypeVar("ParentType", bound="Report | MergedReport")  #: The report a :class:`File` belongs to.


@export
class FormatVersion(StringEnum):
	"""
	Version of GHDL's coverage format, as a coverage file's ``version`` states it.

	GHDL's writer :file:`src/ghdldrv/ghdlcovout.adb` writes only version ``1.0.0``.
	"""

	Version1_0_0 = "1.0.0"  #: Format version 1.0.0.


SCHEMAS: dict[FormatVersion, str] = {
	FormatVersion.Version1_0_0: "GHDL-Coverage-1.0.0.schema.json",
}  #: The JSON Schema a coverage file is validated against, per format version.


@export
class CoverageMode(StringEnum):
	"""
	Kind of coverage of a source file, as stated by an entry's ``mode``.

	GHDL writes only ``stmt``: its writer :file:`src/ghdldrv/ghdlcovout.adb` knows no other kind of coverage, and its
	reader :file:`src/ghdldrv/ghdlcov.adb` skips the field.
	"""

	Statement = "stmt"  #: Statement coverage: per line with a statement, whether a statement of the line ran.


@export
class File(Generic[ParentType], metaclass=ExtendedType, slots=True):
	"""
	An entry of ``outputs``: a source file, where it was analyzed, its checksum, and its lines with coverage points.
	"""

	_parent:    Nullable[ParentType]  #: The report the file belongs to.
	_name:      _Path                 #: The file's name, as given to the analysis.
	_directory: _Path                 #: The directory the file was analyzed in; ``.`` if GHDL ran there.
	_sha1:      str                   #: SHA-1 checksum of the file's content.
	_mode:      CoverageMode          #: Kind of coverage, e.g. statement coverage.
	_lastLine:  int                   #: The last line with a coverage point.
	_result:    dict[int, bool]       #: Per line with a coverage point, whether the line ran.

	def __init__(
		self,
		name:      Path,
		directory: Path,
		sha1:      str,
		mode:      CoverageMode,
		lastLine:  int,
		result:    dict[int, bool],
		*,
		parent:    Nullable[ParentType] = None
	) -> None:
		"""
		Initialize the file from the fields of its JSON object, and add it to the files of its report.

		:param name:        The file's name, as given to the analysis.
		:param directory:   The directory the file was analyzed in; ``.`` for the directory GHDL ran in.
		:param sha1:        SHA-1 checksum of the file's content.
		:param mode:        Kind of coverage, e.g. statement coverage.
		:param lastLine:    The last line with a coverage point.
		:param result:      Per line with a coverage point, whether the line ran.
		:param parent:      Optional, the report the file belongs to; the file is added to its files by :attr:`Path`.
		                    Default: ``None``.
		:raises ValueError: If parameter ``name`` is ``None``.
		:raises TypeError:  If parameter ``name`` isn't of type :class:`~pathlib.Path`.
		:raises ValueError: If parameter ``directory`` is ``None``.
		:raises TypeError:  If parameter ``directory`` isn't of type :class:`~pathlib.Path`.
		:raises ValueError: If parameter ``sha1`` is ``None``.
		:raises TypeError:  If parameter ``sha1`` isn't of type :class:`str`.
		:raises ValueError: If parameter ``sha1`` isn't 40 lowercase hexadecimal digits.
		:raises ValueError: If parameter ``mode`` is ``None``.
		:raises TypeError:  If parameter ``mode`` isn't of type :class:`CoverageMode`.
		:raises ValueError: If parameter ``lastLine`` is ``None``.
		:raises TypeError:  If parameter ``lastLine`` isn't of type :class:`int`.
		:raises ValueError: If parameter ``lastLine`` is less than 1.
		:raises ValueError: If parameter ``result`` is ``None``.
		:raises TypeError:  If parameter ``result`` isn't of type :class:`dict`.
		:raises TypeError:  If parameter ``result`` contains a line number not of type :class:`int`.
		:raises ValueError: If parameter ``result`` contains a line number less than 1.
		:raises ValueError: If parameter ``result`` contains a line number greater than ``lastLine``.
		:raises TypeError:  If parameter ``result`` contains a flag not of type :class:`bool`.
		:raises TypeError:  If parameter ``parent`` isn't of type :class:`Report` or :class:`MergedReport`.
		:raises ValueError: If parameter ``parent`` contains a file of the same path already.
		"""
		if name is None:
			raise ValueError("Parameter 'name' is None.")
		elif not isinstance(name, Path):
			ex = TypeError("Parameter 'name' is not of type 'Path'.")
			ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
			raise ex

		if directory is None:
			raise ValueError("Parameter 'directory' is None.")
		elif not isinstance(directory, Path):
			ex = TypeError("Parameter 'directory' is not of type 'Path'.")
			ex.add_note(f"Got type '{getFullyQualifiedName(directory)}'.")
			raise ex

		if sha1 is None:
			raise ValueError("Parameter 'sha1' is None.")
		elif not isinstance(sha1, str):
			ex = TypeError("Parameter 'sha1' is not of type 'str'.")
			ex.add_note(f"Got type '{getFullyQualifiedName(sha1)}'.")
			raise ex
		elif fullmatch(r"[0-9a-f]{40}", sha1) is None:
			ex = ValueError("Parameter 'sha1' is not 40 lowercase hexadecimal digits.")
			ex.add_note(f"Got value '{sha1}'.")
			raise ex

		if mode is None:
			raise ValueError("Parameter 'mode' is None.")
		elif not isinstance(mode, CoverageMode):
			ex = TypeError("Parameter 'mode' is not of type 'CoverageMode'.")
			ex.add_note(f"Got type '{getFullyQualifiedName(mode)}'.")
			raise ex

		if lastLine is None:
			raise ValueError("Parameter 'lastLine' is None.")
		elif not isinstance(lastLine, int):
			ex = TypeError("Parameter 'lastLine' is not of type 'int'.")
			ex.add_note(f"Got type '{getFullyQualifiedName(lastLine)}'.")
			raise ex
		elif lastLine < 1:
			ex = ValueError("Parameter 'lastLine' is less than 1.")
			ex.add_note(f"Got value '{lastLine}'.")
			raise ex

		if result is None:
			raise ValueError("Parameter 'result' is None.")
		elif not isinstance(result, dict):
			ex = TypeError("Parameter 'result' is not of type 'dict'.")
			ex.add_note(f"Got type '{getFullyQualifiedName(result)}'.")
			raise ex

		for number, ran in result.items():
			if not isinstance(number, int):
				ex = TypeError("Parameter 'result' contains a line number not of type 'int'.")
				ex.add_note(f"Got type '{getFullyQualifiedName(number)}'.")
				raise ex
			elif number < 1:
				ex = ValueError("Parameter 'result' contains a line number less than 1.")
				ex.add_note(f"Got value '{number}'.")
				raise ex
			elif number > lastLine:
				ex = ValueError("Parameter 'result' contains a line number greater than parameter 'lastLine'.")
				ex.add_note(f"Got line {number} for 'lastLine' {lastLine}.")
				raise ex
			elif not isinstance(ran, bool):
				ex = TypeError("Parameter 'result' contains a flag not of type 'bool'.")
				ex.add_note(f"Got type '{getFullyQualifiedName(ran)}'.")
				raise ex

		if parent is not None:
			if not isinstance(parent, (Report, MergedReport)):
				ex = TypeError("Parameter 'parent' is not of type 'Report' or 'MergedReport'.")
				ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
				raise ex
			elif (path := directory / name) in parent._files:
				raise ValueError(f"Parameter 'parent' contains file '{path.as_posix()}' already.")

		self._parent =    parent
		self._name =      name
		self._directory = directory
		self._sha1 =      sha1
		self._mode =      mode
		self._lastLine =  lastLine
		self._result =    result

		if parent is not None:
			parent._files[self.Path] = self

	@readonly
	def Parent(self) -> Nullable[ParentType]:
		"""
		Read-only property to access the report the file belongs to (:attr:`_parent`).

		:returns: The :class:`Report` or :class:`MergedReport`; ``None`` if the file belongs to no report.
		"""
		return self._parent

	@readonly
	def Name(self) -> Path:
		"""
		Read-only property to access the file's name, as given to the analysis (:attr:`_name`).

		A file analyzed as ``ghdl -a src/Counter.vhdl`` is named ``src/Counter.vhdl``.

		:returns: The name.
		"""
		return self._name

	@readonly
	def Directory(self) -> Path:
		"""
		Read-only property to access the directory the file was analyzed in (:attr:`_directory`).

		:returns: The directory; ``.`` for the directory GHDL ran in and for an absolute name.
		"""
		return self._directory

	@readonly
	def Path(self) -> Path:
		"""
		Read-only property to return the file's path: its :attr:`Name`, prefixed by its :attr:`Directory` unless ``.``.

		:returns: The path, as ``ghdl coverage`` names the file.
		"""
		return self._directory / self._name

	@readonly
	def SHA1(self) -> str:
		"""
		Read-only property to access the SHA-1 checksum of the file's content (:attr:`_sha1`).

		:returns: The checksum, 40 hexadecimal digits.
		"""
		return self._sha1

	@readonly
	def Mode(self) -> CoverageMode:
		"""
		Read-only property to access the kind of coverage (:attr:`_mode`).

		GHDL writes only statement coverage (:attr:`CoverageMode.Statement`).

		:returns: The kind of coverage.
		"""
		return self._mode

	@readonly
	def LastLine(self) -> int:
		"""
		Read-only property to access the last line with a coverage point (:attr:`_lastLine`).

		:returns: The line number.
		"""
		return self._lastLine

	@readonly
	def Result(self) -> dict[int, bool]:
		"""
		Read-only property to access whether each line with a coverage point ran (:attr:`_result`).

		:returns: Per line number, ``True`` if a statement of the line ran.
		"""
		return self._result


@export
class Report(metaclass=ExtendedType, mixin=True):
	"""
	The coverage file's root: the format's version, when it was written, and the source files.
	"""

	_version:   Nullable[FormatVersion]   #: Version of the format.
	_testcase:  Nullable[str]             #: Name of the testcase.
	_timestamp: Nullable[datetime]        #: Time the file was written, UTC.
	_files:     dict[Path, File[Report]]  #: The source files, by path.

	def __init__(self) -> None:
		"""
		Initialize an empty report.
		"""
		self._version =   None
		self._testcase =  None
		self._timestamp = None
		self._files =     {}

	@readonly
	def Version(self) -> Nullable[FormatVersion]:
		"""
		Read-only property to access the version of the format (:attr:`_version`).

		:returns: The version; ``None`` before the coverage file was converted.
		"""
		return self._version

	@readonly
	def Testcase(self) -> Nullable[str]:
		"""
		Read-only property to access the name of the testcase (:attr:`_testcase`). GHDL writes ``unknown``.

		:returns: The name; ``None`` before the coverage file was converted.
		"""
		return self._testcase

	@readonly
	def Timestamp(self) -> Nullable[datetime]:
		"""
		Read-only property to access the time the coverage file was written (:attr:`_timestamp`).

		GHDL states it in UTC, to the millisecond, as ``YYYYMMDDhhmmss.mmm``.

		:returns: The time, with time zone UTC; ``None`` before the coverage file was converted.
		"""
		return self._timestamp

	@readonly
	def Files(self) -> dict[Path, File[Report]]:
		"""
		Read-only property to access the source files (:attr:`_files`).

		:returns: The files, by :attr:`File.Path`.
		"""
		return self._files


@export
class Document(cc_Document, Report):
	"""
	A GHDL coverage file: read into the format's model, and converted to the common model.
	"""

	_jsonDocument: Nullable[dict[str, Any]]  #: The parsed and validated JSON document, after :meth:`Analyze`.

	def __init__(self, jsonReportFile: Path, analyzeAndConvert: bool = False) -> None:
		"""
		Initialize the report, and optionally read it.

		:param jsonReportFile:    Path to the JSON file.
		:param analyzeAndConvert: Optional, if true, analyze the file and convert its content. Default: ``False``.
		"""
		super().__init__(jsonReportFile)
		Report.__init__(self)

		self._jsonDocument = None

		if analyzeAndConvert:
			self.Analyze()
			self.Convert()

	def Analyze(self) -> None:
		"""
		Parse the JSON file, read its format version and validate it against the version's JSON Schema in :data:`SCHEMAS`.

		:raises CodeCoverageError: If the file doesn't exist.
		:raises CodeCoverageError: If the file can't be read.
		:raises CodeCoverageError: If the file isn't UTF-8 encoded.
		:raises CodeCoverageError: If the file isn't valid JSON.
		:raises CodeCoverageError: If the file states no format version. |br|
		                           The note lists the supported format versions.
		:raises CodeCoverageError: If the file states an unsupported format version. |br|
		                           The note lists the supported format versions.
		:raises CodeCoverageError: If the JSON Schema can't be read.
		:raises CodeCoverageError: If the file isn't valid according to the JSON Schema.
		"""
		if not self._path.exists():
			raise CodeCoverageError(f"GHDL coverage file '{self._path}' does not exist.") \
				from FileNotFoundError(f"File '{self._path}' not found.")

		with Stopwatch() as sw:
			try:
				content = self._path.read_text(encoding="utf-8")
			except OSError as ex:
				raise CodeCoverageError(f"Couldn't read GHDL coverage file '{self._path}'.") from ex
			except UnicodeDecodeError as ex:
				raise CodeCoverageError(f"GHDL coverage file '{self._path}' is not UTF-8 encoded.") from ex

			try:
				jsonDocument = loads(content)
			except JSONDecodeError as ex:
				raise CodeCoverageError(f"JSON syntax error in GHDL coverage file '{self._path}'.") from ex

			version = jsonDocument.get("version", None) if isinstance(jsonDocument, dict) else None
			try:
				formatVersion = FormatVersion.Parse(version)
			except (TypeError, ValueError) as cause:
				ex = CodeCoverageError(f"GHDL coverage file '{self._path}' states unsupported format version '{version}'.")
				ex.add_note(f"Supported format versions: {', '.join(FormatVersion)}.")
				raise ex from cause

			if formatVersion is None:
				ex = CodeCoverageError(f"GHDL coverage file '{self._path}' states no format version.")
				ex.add_note(f"Supported format versions: {', '.join(FormatVersion)}.")
				raise ex

			schemaFile = SCHEMAS[formatVersion]
			try:
				schema = loads(readResourceFile(Resources, schemaFile))
			except (ToolingException, JSONDecodeError) as ex:
				raise CodeCoverageError(f"Couldn't read JSON Schema '{schemaFile}' from package resources.") from ex

			errors = sorted(Draft202012Validator(schema).iter_errors(jsonDocument), key=lambda error: list(error.path))
			if len(errors) > 0:
				ex = CodeCoverageError(f"Validation error for '{self._path}' using JSON Schema '{schemaFile}'.")
				for error in errors:
					ex.add_note(f"/{'/'.join(str(part) for part in error.path)}: {error.message}")
				raise ex

			self._jsonDocument = jsonDocument

		self._analysisDuration = sw.Duration

	def Convert(self) -> None:
		"""
		Convert the parsed and validated JSON document to the format's model.

		:raises CodeCoverageError: If the JSON file was not analyzed before. |br|
		                           Call 'Document.Analyze()' or create the document using
		                           'Document(path, analyzeAndConvert=True)'.
		:raises CodeCoverageError: If the timestamp isn't a valid date and time.
		:raises CodeCoverageError: If the JSON file names a source file twice.
		:raises CodeCoverageError: If a source file's result names a line beyond its ``max-line``.
		"""
		if self._jsonDocument is None:
			ex = CodeCoverageError(f"GHDL coverage file '{self._path}' needs to be read and analyzed by a JSON parser.")
			ex.add_note("Call 'Document.Analyze()' or create the document using 'Document(path, analyzeAndConvert=True)'.")
			raise ex

		with Stopwatch() as sw:
			try:
				timestamp = datetime.strptime(self._jsonDocument["timestamp"], "%Y%m%d%H%M%S.%f")
			except ValueError as cause:
				ex = CodeCoverageError(f"GHDL coverage file '{self._path}' states a malformed timestamp.")
				ex.add_note(f"Got value '{self._jsonDocument['timestamp']}' at '/timestamp'.")
				raise ex from cause

			self._version =   FormatVersion.Parse(self._jsonDocument["version"])
			self._testcase =  self._jsonDocument["testcase"]
			self._timestamp = timestamp.replace(tzinfo=timezone.utc)

			for output in self._jsonDocument["outputs"]:
				name =      Path(output["file"].replace("\\", "/"))
				directory = Path(output["dir"].replace("\\", "/"))
				lastLine =  output["max-line"]
				result =    {int(number): ran == 1 for number, ran in output["result"].items()}

				if (path := directory / name) in self._files:
					raise CodeCoverageError(f"GHDL coverage file '{self._path}' names source file '{path.as_posix()}' twice.")
				elif (line := max(result)) > lastLine:
					ex = CodeCoverageError(
						f"GHDL coverage file '{self._path}' names a line of '{path.as_posix()}' beyond 'max-line'."
					)
					ex.add_note(f"Got line {line} for 'max-line' {lastLine}.")
					raise ex

				File(name, directory, output["sha1"], CoverageMode.Parse(output["mode"]), lastLine, result, parent=self)

		self._conversionDuration = sw.Duration

	def ToCoverageSummary(self) -> CoverageSummary:
		"""
		Convert the format's model to the common model, and aggregate it.

		:returns:                  The report's root of the common model, named after the coverage file.
		:raises CodeCoverageError: If a file's path runs through another file.
		"""
		return MergedReport(self._path.stem, (self, )).ToCoverageSummary()


@export
class MergedReport(metaclass=ExtendedType, slots=True):
	"""
	The coverage files of several simulation runs, merged: a line ran, if it ran in one of the runs.

	``ghdl coverage`` reads several coverage files too, but a line's result there is the result of the last file naming
	the line.
	"""

	_name:    str                             #: Name of the merged report.
	_reports: list[Report]                    #: The merged reports, in the order they were merged.
	_files:   dict[Path, File[MergedReport]]  #: The merged source files, by path.

	def __init__(self, name: str, reports: Nullable[Iterable[Report]] = None) -> None:
		"""
		Initialize a merged report, and merge the given reports.

		:param name:               Name of the merged report, e.g. of the design simulated.
		:param reports:            Optional, the reports to merge. Default: ``None``.
		:raises ValueError:        If parameter ``name`` is ``None``.
		:raises TypeError:         If parameter ``name`` isn't of type :class:`str`.
		:raises ValueError:        If parameter ``name`` is empty.
		:raises TypeError:         If parameter ``reports`` isn't iterable.
		:raises TypeError:         If parameter ``reports`` contains an element not of type :class:`Report`.
		:raises CodeCoverageError: If a source file's checksum differs between two reports.
		"""
		if name is None:
			raise ValueError("Parameter 'name' is None.")
		elif not isinstance(name, str):
			ex = TypeError("Parameter 'name' is not of type 'str'.")
			ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
			raise ex
		elif name == "":
			raise ValueError("Parameter 'name' is empty.")

		self._name =    name
		self._reports = []
		self._files =   {}

		if reports is not None:
			if not isinstance(reports, Iterable):
				ex = TypeError("Parameter 'reports' is not iterable.")
				ex.add_note(f"Got type '{getFullyQualifiedName(reports)}'.")
				raise ex

			for report in reports:
				if not isinstance(report, Report):
					ex = TypeError("Parameter 'reports' contains an element not of type 'Report'.")
					ex.add_note(f"Got type '{getFullyQualifiedName(report)}'.")
					raise ex

				self.Merge(report)

	def Merge(self, report: Report) -> None:
		"""
		Merge a report: add its source files, or merge them into the files of the same path.

		A merged line ran, if it ran in one of the reports; a merged file's :attr:`File.LastLine` is the largest.

		:param report:             The report to merge.
		:raises ValueError:        If parameter ``report`` is ``None``.
		:raises TypeError:         If parameter ``report`` isn't of type :class:`Report`.
		:raises CodeCoverageError: If a source file's checksum differs from the checksum in a report merged before.
		"""
		if report is None:
			raise ValueError("Parameter 'report' is None.")
		elif not isinstance(report, Report):
			ex = TypeError("Parameter 'report' is not of type 'Report'.")
			ex.add_note(f"Got type '{getFullyQualifiedName(report)}'.")
			raise ex

		for path, file in report._files.items():
			if (merged := self._files.get(path)) is None:
				File(
					file._name, file._directory, file._sha1, file._mode, file._lastLine, dict(file._result), parent=self
				)
			elif merged._sha1 != file._sha1:
				ex = CodeCoverageError(f"Content of source file '{path.as_posix()}' differs from the reports merged before.")
				ex.add_note(f"Got SHA-1 checksum '{file._sha1}' instead of '{merged._sha1}'.")
				raise ex
			else:
				merged._lastLine = max(merged._lastLine, file._lastLine)
				result = merged._result
				for number, ran in file._result.items():
					result[number] = ran or result.get(number, False)

		self._reports.append(report)

	@readonly
	def Name(self) -> str:
		"""
		Read-only property to access the name of the merged report (:attr:`_name`).

		:returns: The name.
		"""
		return self._name

	@readonly
	def Reports(self) -> list[Report]:
		"""
		Read-only property to access the merged reports (:attr:`_reports`).

		:returns: The reports, in the order they were merged.
		"""
		return self._reports

	@readonly
	def Files(self) -> dict[Path, File[MergedReport]]:
		"""
		Read-only property to access the merged source files (:attr:`_files`).

		:returns: The files, by :attr:`File.Path`.
		"""
		return self._files

	def ToCoverageSummary(self) -> CoverageSummary:
		"""
		Convert the merged source files to the common model, and aggregate it.

		:returns:                  The report's root of the common model, named after the merged report.
		:raises CodeCoverageError: If a file's path runs through another file.
		"""
		summary = CoverageSummary(self._name)

		for path, file in self._files.items():
			commonFile = summary.GetOrAddFile(path)
			result =     file._result
			for number in sorted(result):
				cc_Line(
					number,
					LineCoverageStatus.Covered if result[number] else LineCoverageStatus.Uncovered,
					parent=commonFile
				)

		summary.Aggregate()
		return summary