edfcore 0.1.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.
Files changed (188) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +319 -0
  3. package/dist/bytes/latin1.d.ts +43 -0
  4. package/dist/bytes/latin1.d.ts.map +1 -0
  5. package/dist/bytes/latin1.js +84 -0
  6. package/dist/bytes/latin1.js.map +1 -0
  7. package/dist/bytes/numbers.d.ts +45 -0
  8. package/dist/bytes/numbers.d.ts.map +1 -0
  9. package/dist/bytes/numbers.js +87 -0
  10. package/dist/bytes/numbers.js.map +1 -0
  11. package/dist/bytes/view.d.ts +27 -0
  12. package/dist/bytes/view.d.ts.map +1 -0
  13. package/dist/bytes/view.js +53 -0
  14. package/dist/bytes/view.js.map +1 -0
  15. package/dist/constants.d.ts +113 -0
  16. package/dist/constants.d.ts.map +1 -0
  17. package/dist/constants.js +83 -0
  18. package/dist/constants.js.map +1 -0
  19. package/dist/decode/digital.d.ts +49 -0
  20. package/dist/decode/digital.d.ts.map +1 -0
  21. package/dist/decode/digital.js +180 -0
  22. package/dist/decode/digital.js.map +1 -0
  23. package/dist/decode/physical.d.ts +37 -0
  24. package/dist/decode/physical.d.ts.map +1 -0
  25. package/dist/decode/physical.js +178 -0
  26. package/dist/decode/physical.js.map +1 -0
  27. package/dist/diagnostics/codes.d.ts +38 -0
  28. package/dist/diagnostics/codes.d.ts.map +1 -0
  29. package/dist/diagnostics/codes.js +134 -0
  30. package/dist/diagnostics/codes.js.map +1 -0
  31. package/dist/diagnostics/collector.d.ts +76 -0
  32. package/dist/diagnostics/collector.d.ts.map +1 -0
  33. package/dist/diagnostics/collector.js +95 -0
  34. package/dist/diagnostics/collector.js.map +1 -0
  35. package/dist/diagnostics/format.d.ts +21 -0
  36. package/dist/diagnostics/format.d.ts.map +1 -0
  37. package/dist/diagnostics/format.js +125 -0
  38. package/dist/diagnostics/format.js.map +1 -0
  39. package/dist/errors.d.ts +143 -0
  40. package/dist/errors.d.ts.map +1 -0
  41. package/dist/errors.js +135 -0
  42. package/dist/errors.js.map +1 -0
  43. package/dist/header/dates.d.ts +101 -0
  44. package/dist/header/dates.d.ts.map +1 -0
  45. package/dist/header/dates.js +378 -0
  46. package/dist/header/dates.js.map +1 -0
  47. package/dist/header/fields.d.ts +105 -0
  48. package/dist/header/fields.d.ts.map +1 -0
  49. package/dist/header/fields.js +363 -0
  50. package/dist/header/fields.js.map +1 -0
  51. package/dist/header/identification.d.ts +47 -0
  52. package/dist/header/identification.d.ts.map +1 -0
  53. package/dist/header/identification.js +156 -0
  54. package/dist/header/identification.js.map +1 -0
  55. package/dist/header/lookup.d.ts +33 -0
  56. package/dist/header/lookup.d.ts.map +1 -0
  57. package/dist/header/lookup.js +67 -0
  58. package/dist/header/lookup.js.map +1 -0
  59. package/dist/header/parse.d.ts +32 -0
  60. package/dist/header/parse.d.ts.map +1 -0
  61. package/dist/header/parse.js +319 -0
  62. package/dist/header/parse.js.map +1 -0
  63. package/dist/header/scale.d.ts +68 -0
  64. package/dist/header/scale.d.ts.map +1 -0
  65. package/dist/header/scale.js +154 -0
  66. package/dist/header/scale.js.map +1 -0
  67. package/dist/header/signals.d.ts +64 -0
  68. package/dist/header/signals.d.ts.map +1 -0
  69. package/dist/header/signals.js +374 -0
  70. package/dist/header/signals.js.map +1 -0
  71. package/dist/header/variant.d.ts +67 -0
  72. package/dist/header/variant.d.ts.map +1 -0
  73. package/dist/header/variant.js +180 -0
  74. package/dist/header/variant.js.map +1 -0
  75. package/dist/index.d.ts +46 -0
  76. package/dist/index.d.ts.map +1 -0
  77. package/dist/index.js +59 -0
  78. package/dist/index.js.map +1 -0
  79. package/dist/inspect.d.ts +31 -0
  80. package/dist/inspect.d.ts.map +1 -0
  81. package/dist/inspect.js +165 -0
  82. package/dist/inspect.js.map +1 -0
  83. package/dist/io/blob.d.ts +14 -0
  84. package/dist/io/blob.d.ts.map +1 -0
  85. package/dist/io/blob.js +29 -0
  86. package/dist/io/blob.js.map +1 -0
  87. package/dist/io/bytes.d.ts +11 -0
  88. package/dist/io/bytes.d.ts.map +1 -0
  89. package/dist/io/bytes.js +26 -0
  90. package/dist/io/bytes.js.map +1 -0
  91. package/dist/io/cached.d.ts +19 -0
  92. package/dist/io/cached.d.ts.map +1 -0
  93. package/dist/io/cached.js +129 -0
  94. package/dist/io/cached.js.map +1 -0
  95. package/dist/io/http.d.ts +20 -0
  96. package/dist/io/http.d.ts.map +1 -0
  97. package/dist/io/http.js +225 -0
  98. package/dist/io/http.js.map +1 -0
  99. package/dist/io/read.d.ts +42 -0
  100. package/dist/io/read.d.ts.map +1 -0
  101. package/dist/io/read.js +119 -0
  102. package/dist/io/read.js.map +1 -0
  103. package/dist/io/source.d.ts +33 -0
  104. package/dist/io/source.d.ts.map +1 -0
  105. package/dist/io/source.js +72 -0
  106. package/dist/io/source.js.map +1 -0
  107. package/dist/node.d.ts +70 -0
  108. package/dist/node.d.ts.map +1 -0
  109. package/dist/node.js +109 -0
  110. package/dist/node.js.map +1 -0
  111. package/dist/record-index.d.ts +58 -0
  112. package/dist/record-index.d.ts.map +1 -0
  113. package/dist/record-index.js +290 -0
  114. package/dist/record-index.js.map +1 -0
  115. package/dist/recording.d.ts +57 -0
  116. package/dist/recording.d.ts.map +1 -0
  117. package/dist/recording.js +178 -0
  118. package/dist/recording.js.map +1 -0
  119. package/dist/tal/annotations.d.ts +36 -0
  120. package/dist/tal/annotations.d.ts.map +1 -0
  121. package/dist/tal/annotations.js +437 -0
  122. package/dist/tal/annotations.js.map +1 -0
  123. package/dist/tal/grammar.d.ts +111 -0
  124. package/dist/tal/grammar.d.ts.map +1 -0
  125. package/dist/tal/grammar.js +324 -0
  126. package/dist/tal/grammar.js.map +1 -0
  127. package/dist/tal/ticks.d.ts +69 -0
  128. package/dist/tal/ticks.d.ts.map +1 -0
  129. package/dist/tal/ticks.js +137 -0
  130. package/dist/tal/ticks.js.map +1 -0
  131. package/dist/time/segments.d.ts +33 -0
  132. package/dist/time/segments.d.ts.map +1 -0
  133. package/dist/time/segments.js +87 -0
  134. package/dist/time/segments.js.map +1 -0
  135. package/dist/time/timeline.d.ts +62 -0
  136. package/dist/time/timeline.d.ts.map +1 -0
  137. package/dist/time/timeline.js +227 -0
  138. package/dist/time/timeline.js.map +1 -0
  139. package/dist/time/window.d.ts +54 -0
  140. package/dist/time/window.d.ts.map +1 -0
  141. package/dist/time/window.js +232 -0
  142. package/dist/time/window.js.map +1 -0
  143. package/dist/types.d.ts +409 -0
  144. package/dist/types.d.ts.map +1 -0
  145. package/dist/types.js +16 -0
  146. package/dist/types.js.map +1 -0
  147. package/dist/validate.d.ts +53 -0
  148. package/dist/validate.d.ts.map +1 -0
  149. package/dist/validate.js +483 -0
  150. package/dist/validate.js.map +1 -0
  151. package/package.json +77 -0
  152. package/src/bytes/latin1.ts +87 -0
  153. package/src/bytes/numbers.ts +115 -0
  154. package/src/bytes/view.ts +59 -0
  155. package/src/constants.ts +96 -0
  156. package/src/decode/digital.ts +258 -0
  157. package/src/decode/physical.ts +235 -0
  158. package/src/diagnostics/codes.ts +209 -0
  159. package/src/diagnostics/collector.ts +129 -0
  160. package/src/diagnostics/format.ts +143 -0
  161. package/src/errors.ts +208 -0
  162. package/src/header/dates.ts +453 -0
  163. package/src/header/fields.ts +455 -0
  164. package/src/header/identification.ts +196 -0
  165. package/src/header/lookup.ts +81 -0
  166. package/src/header/parse.ts +398 -0
  167. package/src/header/scale.ts +268 -0
  168. package/src/header/signals.ts +550 -0
  169. package/src/header/variant.ts +239 -0
  170. package/src/index.ts +151 -0
  171. package/src/inspect.ts +181 -0
  172. package/src/io/blob.ts +31 -0
  173. package/src/io/bytes.ts +29 -0
  174. package/src/io/cached.ts +136 -0
  175. package/src/io/http.ts +299 -0
  176. package/src/io/read.ts +152 -0
  177. package/src/io/source.ts +87 -0
  178. package/src/node.ts +138 -0
  179. package/src/record-index.ts +389 -0
  180. package/src/recording.ts +234 -0
  181. package/src/tal/annotations.ts +574 -0
  182. package/src/tal/grammar.ts +540 -0
  183. package/src/tal/ticks.ts +172 -0
  184. package/src/time/segments.ts +109 -0
  185. package/src/time/timeline.ts +290 -0
  186. package/src/time/window.ts +304 -0
  187. package/src/types.ts +490 -0
  188. package/src/validate.ts +610 -0
@@ -0,0 +1,209 @@
1
+ /**
2
+ * The diagnostic vocabulary.
3
+ *
4
+ * Layer 1. Imports nothing, so every other module may depend on it.
5
+ *
6
+ * A diagnostic is a *value*, never a log line. Each code has exactly one severity and one
7
+ * disposition, both fixed here:
8
+ *
9
+ * - `fatal` — edfcore cannot proceed without inventing something. Always throws, even when
10
+ * `strict` is false.
11
+ * - `deferred` — the header parses, but one signal cannot be scaled. `signal.scale` becomes
12
+ * `undefined`, `decodeDigital` keeps working, and `toPhysical` throws.
13
+ * - `warning` — the file is impolite but readable, and what we return is true.
14
+ * - `info` — the file is correct; the note exists because the situation surprises people.
15
+ */
16
+
17
+ export type EdfSeverity = 'error' | 'warning' | 'info';
18
+
19
+ /** How a code behaves when it fires. See the module comment. */
20
+ export type EdfDiagnosticDisposition = 'fatal' | 'deferred' | 'warning' | 'info';
21
+
22
+ /**
23
+ * Every code edfcore itself can emit, grouped by disposition.
24
+ *
25
+ * This union and the `DISPOSITIONS` map below are checked against each other by the compiler:
26
+ * the map is typed as a total `Record` over this union, so a code added to one and not the
27
+ * other is a build error rather than a silent gap.
28
+ */
29
+ export type EdfKnownDiagnosticCode =
30
+ // Always fatal
31
+ | 'NOT_AN_EDF_FILE'
32
+ | 'SOURCE_TOO_SMALL'
33
+ | 'SIGNAL_COUNT_INVALID'
34
+ | 'NUMERIC_FIELD_INVALID'
35
+ | 'COMMA_DECIMAL_SEPARATOR'
36
+ | 'RECORD_SIZE_ZERO'
37
+ | 'EDFPLUS_WITHOUT_ANNOTATION_SIGNAL'
38
+ | 'TIMELINE_NOT_MONOTONIC'
39
+ // Deferred-fatal: header parses, scaling does not
40
+ | 'DEGENERATE_DIGITAL_RANGE'
41
+ | 'DEGENERATE_PHYSICAL_RANGE'
42
+ | 'INVERTED_DIGITAL_RANGE'
43
+ | 'LOG_TRANSFORMED_CHANNEL'
44
+ | 'SCALE_UNAVAILABLE'
45
+ // Warnings
46
+ | 'HEADER_SIZE_MISMATCH'
47
+ | 'RECORD_COUNT_RECOVERED'
48
+ | 'TRUNCATED_FILE'
49
+ | 'PARTIAL_FINAL_RECORD'
50
+ | 'TRAILING_BYTES'
51
+ | 'RECORD_SIZE_ABOVE_RECOMMENDED'
52
+ | 'NONSTANDARD_RESERVED_FIELD'
53
+ | 'NON_ASCII_HEADER_FIELD'
54
+ | 'NUMERIC_FIELD_NOT_LEFT_JUSTIFIED'
55
+ | 'DATE_FIELDS_DISAGREE'
56
+ | 'DATE_UNPARSEABLE'
57
+ | 'PATIENT_ID_NONCONFORMANT'
58
+ | 'RECORDING_ID_NONCONFORMANT'
59
+ | 'DUPLICATE_SIGNAL_LABEL'
60
+ | 'DIGITAL_RANGE_EXCEEDS_FORMAT'
61
+ | 'ZERO_SAMPLES_PER_RECORD'
62
+ | 'ZERO_RECORD_DURATION'
63
+ | 'ANNOTATION_SIGNAL_HEADER_NONCONFORMANT'
64
+ | 'MISSING_EDFPLUS_MARKER'
65
+ | 'TIMEKEEPING_TAL_MISSING'
66
+ | 'TIMEKEEPING_TAL_NONCONFORMANT'
67
+ | 'START_OFFSET_OUT_OF_RANGE'
68
+ | 'TAL_MALFORMED'
69
+ | 'TAL_TRUNCATED_AT_REGION_END'
70
+ | 'TAL_REGION_NOT_NUL_TERMINATED'
71
+ | 'ANNOTATION_TEXT_NOT_UTF8'
72
+ | 'RECORD_ONSET_SPACING_VIOLATION'
73
+ | 'DISCONTINUITY_IN_CONTINUOUS_FILE'
74
+ // Info
75
+ | 'DATE_CLIPPED_TO_1985_2084'
76
+ | 'INVERTED_PHYSICAL_RANGE'
77
+ | 'NEGATIVE_ANNOTATION_ONSET';
78
+
79
+ const DISPOSITIONS: Readonly<Record<EdfKnownDiagnosticCode, EdfDiagnosticDisposition>> = {
80
+ // ---- Always fatal --------------------------------------------------------
81
+ /** No recognisable EDF or BDF version block at offset 0. */
82
+ NOT_AN_EDF_FILE: 'fatal',
83
+ /** Fewer than 256 bytes, or fewer than 256*(ns+1). */
84
+ SOURCE_TOO_SMALL: 'fatal',
85
+ /** ns outside 1..9999, blank, or non-numeric. Every later offset is a multiple of it. */
86
+ SIGNAL_COUNT_INVALID: 'fatal',
87
+ /** A field the file geometry depends on failed its grammar end-to-end. */
88
+ NUMERIC_FIELD_INVALID: 'fatal',
89
+ /** '0,5' and '1,024' are indistinguishable; guessing turns 1024 into 1.024. */
90
+ COMMA_DECIMAL_SEPARATOR: 'fatal',
91
+ /** Every signal declares 0 samples per record, so records have no size to step by. */
92
+ RECORD_SIZE_ZERO: 'fatal',
93
+ /** EDF+ marker present but no annotation signal: no per-record timing exists, so any
94
+ * time we reported would be invented. */
95
+ EDFPLUS_WITHOUT_ANNOTATION_SIGNAL: 'fatal',
96
+ /** Record onsets went backwards. Every time-based answer would be wrong. */
97
+ TIMELINE_NOT_MONOTONIC: 'fatal',
98
+
99
+ // ---- Deferred-fatal: header parses, scaling does not -----------------------
100
+ /** digitalMinimum === digitalMaximum. Division by zero. EDF+ spec item 5. */
101
+ DEGENERATE_DIGITAL_RANGE: 'deferred',
102
+ /** physicalMinimum === physicalMaximum. Every sample would map to one value. */
103
+ DEGENERATE_PHYSICAL_RANGE: 'deferred',
104
+ /** digitalMinimum > digitalMaximum. Violates EDF+ item 5; we will not guess the intent. */
105
+ INVERTED_DIGITAL_RANGE: 'deferred',
106
+ /** Physical dimension is exactly 'Filtered': values are log-compressed (edffloat.html),
107
+ * so the linear formula would be wrong by orders of magnitude. Refused, not applied. */
108
+ LOG_TRANSFORMED_CHANNEL: 'deferred',
109
+ /** `toPhysical` was called on a signal with no scale and the specific cause was not
110
+ * re-derivable from the signal alone. Naming the wrong cause would be worse. */
111
+ SCALE_UNAVAILABLE: 'deferred',
112
+
113
+ // ---- Warnings: the file stays readable and what we return is true ----------
114
+ /** Header byte-count field disagrees with 256*(ns+1). The computed value always wins. */
115
+ HEADER_SIZE_MISMATCH: 'warning',
116
+ /** recordCount was -1 (writer never closed the file); recovered from the source length. */
117
+ RECORD_COUNT_RECOVERED: 'warning',
118
+ /** The file is shorter than its declared record count implies. */
119
+ TRUNCATED_FILE: 'warning',
120
+ /** A final partial record exists. Only whole records are exposed; nothing is zero-padded. */
121
+ PARTIAL_FINAL_RECORD: 'warning',
122
+ /** Bytes exist beyond the last whole data record. */
123
+ TRAILING_BYTES: 'warning',
124
+ /** Record exceeds the 61440-byte recommendation. */
125
+ RECORD_SIZE_ABOVE_RECOMMENDED: 'warning',
126
+ /** Reserved field is neither blank nor a recognised EDF+/BDF+ marker. */
127
+ NONSTANDARD_RESERVED_FIELD: 'warning',
128
+ /** A header field carries bytes outside printable ASCII. Decoded as Latin-1. */
129
+ NON_ASCII_HEADER_FIELD: 'warning',
130
+ /** A numeric field is right-justified; the spec requires left-justified. */
131
+ NUMERIC_FIELD_NOT_LEFT_JUSTIFIED: 'warning',
132
+ /** Header startdate and the recording-id Startdate disagree. Both are exposed; we pick no winner. */
133
+ DATE_FIELDS_DISAGREE: 'warning',
134
+ /** The date could not be parsed at all. */
135
+ DATE_UNPARSEABLE: 'warning',
136
+ /** Patient identification does not follow the EDF+ subfield grammar. */
137
+ PATIENT_ID_NONCONFORMANT: 'warning',
138
+ /** Recording identification does not follow the EDF+ subfield grammar. */
139
+ RECORDING_ID_NONCONFORMANT: 'warning',
140
+ /** Two signals share a label. `getSignal` by label will throw for these. */
141
+ DUPLICATE_SIGNAL_LABEL: 'warning',
142
+ /** Declared digital range exceeds what the sample width can represent. */
143
+ DIGITAL_RANGE_EXCEEDS_FORMAT: 'warning',
144
+ /** A signal declares 0 samples per record. */
145
+ ZERO_SAMPLES_PER_RECORD: 'warning',
146
+ /** Record duration is 0. Legal in EDF+, but sample rates become undefined. */
147
+ ZERO_RECORD_DURATION: 'warning',
148
+ /** An annotation signal's header fields deviate from the EDF+ requirements. */
149
+ ANNOTATION_SIGNAL_HEADER_NONCONFORMANT: 'warning',
150
+ /** An 'EDF Annotations' signal exists without an EDF+ marker in the reserved field.
151
+ * Annotations are parsed anyway; the channel is never exposed as an ordinary signal. */
152
+ MISSING_EDFPLUS_MARKER: 'warning',
153
+ /** A data record has no timekeeping TAL. */
154
+ TIMEKEEPING_TAL_MISSING: 'warning',
155
+ /** The timekeeping TAL exists but deviates (e.g. the widespread `+t 0x14 0x00` shorthand). */
156
+ TIMEKEEPING_TAL_NONCONFORMANT: 'warning',
157
+ /** Record 0's sub-second start offset fell outside [0, 1). */
158
+ START_OFFSET_OUT_OF_RANGE: 'warning',
159
+ /** A TAL did not match the grammar. That TAL is skipped; the file is kept. */
160
+ TAL_MALFORMED: 'warning',
161
+ /** A TAL ran past the end of its annotation region and was discarded. */
162
+ TAL_TRUNCATED_AT_REGION_END: 'warning',
163
+ /** The annotation region tail was not NUL padding. */
164
+ TAL_REGION_NOT_NUL_TERMINATED: 'warning',
165
+ /** Annotation text was not valid UTF-8; decoded as Latin-1 instead. */
166
+ ANNOTATION_TEXT_NOT_UTF8: 'warning',
167
+ /** Consecutive record onsets are not spaced by the record duration. */
168
+ RECORD_ONSET_SPACING_VIOLATION: 'warning',
169
+ /** A file marked EDF+C contains a real discontinuity. */
170
+ DISCONTINUITY_IN_CONTINUOUS_FILE: 'warning',
171
+
172
+ // ---- Info: correct, and deliberately not a warning ------------------------
173
+ /** A two-digit year was resolved through the spec's own 1985..2084 rule. Every conforming
174
+ * EDF file has a two-digit year, so this is the normal path, not a defect — it is
175
+ * reported only because the resolved century is worth being able to see. */
176
+ DATE_CLIPPED_TO_1985_2084: 'info',
177
+ /** physicalMinimum > physicalMaximum encodes a negative amplifier gain. Spec-sanctioned
178
+ * (EDF FAQ Q6). Never "fixed" — swapping them is a silent polarity flip. */
179
+ INVERTED_PHYSICAL_RANGE: 'info',
180
+ /** A negative annotation onset is normal for pre-stimulus events. */
181
+ NEGATIVE_ANNOTATION_ONSET: 'info',
182
+ };
183
+
184
+ /**
185
+ * Open union: known codes autocomplete, and a `default` branch stays mandatory so adding a
186
+ * code in a minor release cannot break a consumer's exhaustive switch.
187
+ */
188
+ export type EdfDiagnosticCode = EdfKnownDiagnosticCode | (string & {});
189
+
190
+ export const DIAGNOSTIC_DISPOSITIONS: Readonly<
191
+ Record<EdfKnownDiagnosticCode, EdfDiagnosticDisposition>
192
+ > = DISPOSITIONS;
193
+
194
+ /** Unknown codes are treated as warnings — an unrecognised note must never escalate. */
195
+ export function dispositionOf(code: EdfDiagnosticCode): EdfDiagnosticDisposition {
196
+ return DISPOSITIONS[code as EdfKnownDiagnosticCode] ?? 'warning';
197
+ }
198
+
199
+ export function severityOf(code: EdfDiagnosticCode): EdfSeverity {
200
+ const disposition = dispositionOf(code);
201
+ if (disposition === 'fatal' || disposition === 'deferred') return 'error';
202
+ if (disposition === 'warning') return 'warning';
203
+ return 'info';
204
+ }
205
+
206
+ /** Fires regardless of `strict`: proceeding would require inventing data. */
207
+ export function isAlwaysFatal(code: EdfDiagnosticCode): boolean {
208
+ return dispositionOf(code) === 'fatal';
209
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * The strict-vs-collect decision.
3
+ *
4
+ * Layer 1. Every diagnostic edfcore emits is built here, which is what makes two invariants
5
+ * structural rather than conventional:
6
+ *
7
+ * - `strict: true` throws `EdfFormatError` carrying the first would-be diagnostic, so under
8
+ * strict every `diagnostics` array is empty by construction;
9
+ * - a code whose disposition is `fatal` throws whether or not `strict` is set, because
10
+ * proceeding would require inventing data.
11
+ *
12
+ * The caller never passes a severity — it is derived from the code by `severityOf`, so one code
13
+ * cannot acquire two severities in two call sites.
14
+ */
15
+
16
+ import { EdfFormatError, type EdfFormatErrorInit } from '../errors.js';
17
+ import type { EdfDiagnostic, ParseOptions } from '../types.js';
18
+ import { type EdfDiagnosticCode, isAlwaysFatal, severityOf } from './codes.js';
19
+
20
+ /**
21
+ * Everything a diagnostic may carry except its severity. Optional here, required-or-undefined
22
+ * on `EdfDiagnostic`: `createDiagnostic` normalises between the two, which is what lets the
23
+ * result type stay exhaustive under `exactOptionalPropertyTypes`.
24
+ */
25
+ export interface DiagnosticInit {
26
+ readonly code: EdfDiagnosticCode;
27
+ /** Must name the field, the raw bytes as written, the rule, and an actionable next step. */
28
+ readonly message: string;
29
+ readonly field?: string;
30
+ readonly byteOffset?: number;
31
+ readonly byteLength?: number;
32
+ readonly rawBytes?: Uint8Array;
33
+ readonly raw?: string;
34
+ readonly expected?: string;
35
+ readonly actual?: string;
36
+ readonly signalIndex?: number;
37
+ readonly recordIndex?: number;
38
+ /** A spec clause, e.g. 'EDF+ additional specification 5'. */
39
+ readonly specReference?: string;
40
+ }
41
+
42
+ export function createDiagnostic(init: DiagnosticInit): EdfDiagnostic {
43
+ return {
44
+ code: init.code,
45
+ severity: severityOf(init.code),
46
+ message: init.message,
47
+ field: init.field,
48
+ byteOffset: init.byteOffset,
49
+ byteLength: init.byteLength,
50
+ // Copied, not aliased: a diagnostic outlives the read that produced it, and the caller's
51
+ // view is typically a subarray of a buffer an I/O adapter is free to reuse.
52
+ rawBytes: init.rawBytes === undefined ? undefined : init.rawBytes.slice(),
53
+ raw: init.raw,
54
+ expected: init.expected,
55
+ actual: init.actual,
56
+ signalIndex: init.signalIndex,
57
+ recordIndex: init.recordIndex,
58
+ specReference: init.specReference,
59
+ };
60
+ }
61
+
62
+ /**
63
+ * The one way to turn a diagnostic into the error that carries it. `EdfFormatError` re-derives
64
+ * `field`/`byteOffset`/`signalIndex`/`recordIndex` from the diagnostic, so they are not repeated.
65
+ */
66
+ export function toFormatError(diagnostic: EdfDiagnostic, cause?: unknown): EdfFormatError {
67
+ const init: EdfFormatErrorInit =
68
+ cause === undefined
69
+ ? { code: diagnostic.code, diagnostic }
70
+ : { code: diagnostic.code, diagnostic, cause };
71
+ return new EdfFormatError(`[${diagnostic.code}] ${diagnostic.message}`, init);
72
+ }
73
+
74
+ /**
75
+ * `toFormatError(createDiagnostic(init))`, for the paths that throw without a sink — an always
76
+ * fatal code detected before one exists, or where the type system needs the `throw` to be
77
+ * visible at the call site.
78
+ */
79
+ export function fatalError(init: DiagnosticInit, cause?: unknown): EdfFormatError {
80
+ return toFormatError(createDiagnostic(init), cause);
81
+ }
82
+
83
+ export class DiagnosticSink {
84
+ /**
85
+ * Readable so a caller can skip building a message it is about to throw away. Never so a
86
+ * caller can re-implement the decision in `report`.
87
+ */
88
+ readonly strict: boolean;
89
+
90
+ #collected: EdfDiagnostic[] = [];
91
+
92
+ constructor(options?: ParseOptions) {
93
+ this.strict = options?.strict === true;
94
+ }
95
+
96
+ get size(): number {
97
+ return this.#collected.length;
98
+ }
99
+
100
+ /**
101
+ * Records the diagnostic, or throws `EdfFormatError` when the code is always fatal, or when
102
+ * `strict` is set and the code describes an actual defect. There is no severity parameter
103
+ * by design.
104
+ *
105
+ * `info` codes are exempt from `strict`. They exist to explain something that is correct but
106
+ * surprising — a spec-sanctioned negative gain, a pre-stimulus onset, the mandated two-digit
107
+ * year rule — so throwing on one would contradict what the severity means, and would make
108
+ * `strict` reject conforming files. Every `info` note is still collected and readable.
109
+ */
110
+ report(init: DiagnosticInit): void {
111
+ const diagnostic = createDiagnostic(init);
112
+ if (isAlwaysFatal(diagnostic.code) || (this.strict && diagnostic.severity !== 'info')) {
113
+ throw toFormatError(diagnostic);
114
+ }
115
+ this.#collected.push(diagnostic);
116
+ }
117
+
118
+ /** A frozen copy: an array already attached to a result must not grow if reporting continues. */
119
+ get diagnostics(): readonly EdfDiagnostic[] {
120
+ return Object.freeze(this.#collected.slice());
121
+ }
122
+
123
+ /** `diagnostics`, then reset — for a sink reused across records. */
124
+ drain(): readonly EdfDiagnostic[] {
125
+ const collected = this.diagnostics;
126
+ this.#collected = [];
127
+ return collected;
128
+ }
129
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Rendering diagnostics for humans.
3
+ *
4
+ * Layer 1. Layout only: by the message contract a diagnostic's own message already names the
5
+ * field, the raw bytes as written, the rule and the next step, so this module adds structure —
6
+ * severity marker, code, location, the bytes as hex — and invents no wording.
7
+ *
8
+ * Output is deterministic and asserted as such: no locale-sensitive number or date formatting,
9
+ * no iteration over an unordered collection, and no ANSI escapes unless `color` is requested.
10
+ */
11
+
12
+ import type { EdfDiagnostic, EdfSeverity } from '../types.js';
13
+
14
+ export interface FormatDiagnosticsOptions {
15
+ readonly color?: boolean;
16
+ readonly maxItems?: number;
17
+ }
18
+
19
+ const INDENT = ' ';
20
+
21
+ /** A report is a summary, not a hex dump; longer runs are elided with a count. */
22
+ const MAX_RAW_BYTES_SHOWN = 24;
23
+
24
+ const ANSI_RESET = '\u001b[0m';
25
+ const ANSI_DIM = '\u001b[2m';
26
+
27
+ const SEVERITY_COLORS: Readonly<Record<EdfSeverity, string>> = {
28
+ error: '\u001b[31m',
29
+ warning: '\u001b[33m',
30
+ info: '\u001b[36m',
31
+ };
32
+
33
+ /**
34
+ * A multi-line report, one block per diagnostic. Returns `''` for an empty list so the result
35
+ * can be concatenated into a larger report without a stray blank line.
36
+ */
37
+ export function formatDiagnostics(
38
+ diagnostics: readonly EdfDiagnostic[],
39
+ options?: FormatDiagnosticsOptions,
40
+ ): string {
41
+ const color = options?.color === true;
42
+ const shown = resolveLimit(options?.maxItems, diagnostics.length);
43
+ const lines: string[] = [];
44
+
45
+ for (let i = 0; i < shown; i++) {
46
+ const diagnostic = diagnostics[i];
47
+ // i < shown <= diagnostics.length, so this only satisfies noUncheckedIndexedAccess.
48
+ if (diagnostic === undefined) continue;
49
+ appendDiagnostic(lines, diagnostic, color);
50
+ }
51
+
52
+ const hidden = diagnostics.length - shown;
53
+ if (hidden > 0) lines.push(paint(`... and ${hidden} more`, ANSI_DIM, color));
54
+
55
+ return lines.join('\n');
56
+ }
57
+
58
+ function resolveLimit(maxItems: number | undefined, total: number): number {
59
+ if (maxItems === undefined || !Number.isFinite(maxItems)) return total;
60
+ return Math.max(0, Math.min(total, Math.floor(maxItems)));
61
+ }
62
+
63
+ function appendDiagnostic(lines: string[], diagnostic: EdfDiagnostic, color: boolean): void {
64
+ const marker = paint(
65
+ `${diagnostic.severity} [${diagnostic.code}]`,
66
+ SEVERITY_COLORS[diagnostic.severity],
67
+ color,
68
+ );
69
+
70
+ const messageLines = diagnostic.message.split('\n');
71
+ const first = messageLines[0] ?? '';
72
+ lines.push(`${marker} ${first}`.trimEnd());
73
+ for (let i = 1; i < messageLines.length; i++) {
74
+ const line = messageLines[i];
75
+ if (line === undefined) continue;
76
+ lines.push(`${INDENT}${line.trim()}`);
77
+ }
78
+
79
+ const location = locationOf(diagnostic);
80
+ if (location !== undefined) detail(lines, location, color);
81
+ if (diagnostic.raw !== undefined) detail(lines, `raw: ${quote(diagnostic.raw)}`, color);
82
+ if (diagnostic.rawBytes !== undefined && diagnostic.rawBytes.length > 0) {
83
+ detail(lines, `bytes: ${hexDump(diagnostic.rawBytes)}`, color);
84
+ }
85
+ if (diagnostic.expected !== undefined) detail(lines, `expected: ${diagnostic.expected}`, color);
86
+ if (diagnostic.actual !== undefined) detail(lines, `actual: ${diagnostic.actual}`, color);
87
+ if (diagnostic.specReference !== undefined) {
88
+ detail(lines, `spec: ${diagnostic.specReference}`, color);
89
+ }
90
+ }
91
+
92
+ function detail(lines: string[], text: string, color: boolean): void {
93
+ lines.push(`${INDENT}${paint(text, ANSI_DIM, color)}`);
94
+ }
95
+
96
+ function locationOf(diagnostic: EdfDiagnostic): string | undefined {
97
+ const parts: string[] = [];
98
+ if (diagnostic.byteOffset !== undefined) {
99
+ parts.push(
100
+ diagnostic.byteLength === undefined
101
+ ? `byte offset ${diagnostic.byteOffset}`
102
+ : `byte offset ${diagnostic.byteOffset} (${diagnostic.byteLength} bytes)`,
103
+ );
104
+ }
105
+ if (diagnostic.field !== undefined) parts.push(diagnostic.field);
106
+ if (diagnostic.signalIndex !== undefined) parts.push(`signal ${diagnostic.signalIndex}`);
107
+ if (diagnostic.recordIndex !== undefined) parts.push(`record ${diagnostic.recordIndex}`);
108
+ return parts.length === 0 ? undefined : `at ${parts.join(', ')}`;
109
+ }
110
+
111
+ /** `30 20 20 20 |0 |`. Built byte by byte — TextDecoder is banned outside `src/tal/`. */
112
+ function hexDump(bytes: Uint8Array): string {
113
+ const shown = bytes.subarray(0, MAX_RAW_BYTES_SHOWN);
114
+ const hex: string[] = [];
115
+ let ascii = '';
116
+ for (const byte of shown) {
117
+ hex.push(byte.toString(16).padStart(2, '0'));
118
+ ascii += isPrintableAscii(byte) ? String.fromCharCode(byte) : '.';
119
+ }
120
+ const elided = bytes.length - shown.length;
121
+ return `${hex.join(' ')} |${ascii}|${elided > 0 ? ` +${elided} more` : ''}`;
122
+ }
123
+
124
+ function isPrintableAscii(byte: number): boolean {
125
+ return byte >= 0x20 && byte <= 0x7e;
126
+ }
127
+
128
+ /** Keeps every entry on one line: control and non-ASCII characters become escapes, not bytes. */
129
+ function quote(value: string): string {
130
+ let out = '"';
131
+ for (const char of value) {
132
+ const code = char.codePointAt(0) ?? 0;
133
+ if (char === '"' || char === '\\') out += `\\${char}`;
134
+ else if (isPrintableAscii(code)) out += char;
135
+ else if (code <= 0xff) out += `\\x${code.toString(16).padStart(2, '0')}`;
136
+ else out += `\\u{${code.toString(16)}}`;
137
+ }
138
+ return `${out}"`;
139
+ }
140
+
141
+ function paint(text: string, color: string, enabled: boolean): string {
142
+ return enabled ? `${color}${text}${ANSI_RESET}` : text;
143
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,208 @@
1
+ /**
2
+ * The error hierarchy.
3
+ *
4
+ * Layer 1. One rule decides which of these you get: if edfcore cannot proceed without
5
+ * inventing something, it throws; if it can proceed truthfully, it records an `EdfDiagnostic`.
6
+ * There is no third category, and there is no `console` call anywhere in this package.
7
+ *
8
+ * `edfErrorKind` exists so consumers can discriminate without `instanceof`, which fails across
9
+ * realms — an iframe, a worker, or two copies of the package in one dependency tree.
10
+ */
11
+
12
+ import type { EdfDiagnostic, EdfDiagnosticCode, RecordRange } from './types.js';
13
+
14
+ export type EdfErrorKind = 'format' | 'scaling' | 'range' | 'source' | 'budget' | 'channel';
15
+
16
+ export abstract class EdfError extends Error {
17
+ abstract readonly edfErrorKind: EdfErrorKind;
18
+
19
+ constructor(message: string, options?: { cause?: unknown }) {
20
+ super(message, options as ErrorOptions | undefined);
21
+ this.name = new.target.name;
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Every concrete error edfcore throws.
27
+ *
28
+ * This exists so `edfErrorKind` actually discriminates in TypeScript. `EdfError` alone
29
+ * declares only the kind, so narrowing against the abstract class leaves the extra fields
30
+ * (`code`, `budgetBytes`, `matchingIndices`, …) unreachable and forces a cast at every call
31
+ * site. Switching on `edfErrorKind` over this union reaches them without one.
32
+ */
33
+ export type AnyEdfError =
34
+ | EdfFormatError
35
+ | EdfScalingError
36
+ | EdfRangeError
37
+ | EdfSourceError
38
+ | EdfBudgetError
39
+ | EdfAmbiguousChannelError
40
+ | EdfChannelNotFoundError;
41
+
42
+ /**
43
+ * Returns true for any error thrown by edfcore, across realms.
44
+ *
45
+ * Checks the `edfErrorKind` brand rather than `instanceof`, which fails whenever the error
46
+ * crossed a realm boundary — an iframe, a worker, or two copies of the package in one
47
+ * dependency tree.
48
+ *
49
+ * Note that a handful of caller-error paths deliberately throw a plain `RangeError` instead
50
+ * (asking for records that cannot exist, or handing an annotation channel to a sample read).
51
+ * Those are bugs in the calling code rather than problems with the file, and this returns
52
+ * false for them.
53
+ */
54
+ export function isEdfError(value: unknown): value is AnyEdfError {
55
+ return (
56
+ typeof value === 'object' &&
57
+ value !== null &&
58
+ typeof (value as { edfErrorKind?: unknown }).edfErrorKind === 'string'
59
+ );
60
+ }
61
+
62
+ export interface EdfFormatErrorInit {
63
+ readonly code: EdfDiagnosticCode;
64
+ readonly diagnostic?: EdfDiagnostic;
65
+ readonly field?: string;
66
+ readonly byteOffset?: number;
67
+ readonly signalIndex?: number;
68
+ readonly recordIndex?: number;
69
+ readonly cause?: unknown;
70
+ }
71
+
72
+ /**
73
+ * The file is wrong. Also what `strict: true` throws, carrying the diagnostic that would
74
+ * otherwise have been collected.
75
+ */
76
+ export class EdfFormatError extends EdfError {
77
+ readonly edfErrorKind = 'format' as const;
78
+ readonly code: EdfDiagnosticCode;
79
+ readonly diagnostic: EdfDiagnostic | undefined;
80
+ readonly field: string | undefined;
81
+ readonly byteOffset: number | undefined;
82
+ readonly signalIndex: number | undefined;
83
+ readonly recordIndex: number | undefined;
84
+
85
+ constructor(message: string, init: EdfFormatErrorInit) {
86
+ super(message, { cause: init.cause });
87
+ this.code = init.code;
88
+ this.diagnostic = init.diagnostic;
89
+ this.field = init.field ?? init.diagnostic?.field;
90
+ this.byteOffset = init.byteOffset ?? init.diagnostic?.byteOffset;
91
+ this.signalIndex = init.signalIndex ?? init.diagnostic?.signalIndex;
92
+ this.recordIndex = init.recordIndex ?? init.diagnostic?.recordIndex;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Physical units are unavailable for one signal, because the header's ranges do not define a
98
+ * scale. `decodeDigital` still works on that signal — edfcore will not invent a gain.
99
+ */
100
+ export class EdfScalingError extends EdfError {
101
+ readonly edfErrorKind = 'scaling' as const;
102
+ readonly code: EdfDiagnosticCode;
103
+ readonly signalIndex: number;
104
+ readonly label: string;
105
+
106
+ constructor(
107
+ message: string,
108
+ init: { code: EdfDiagnosticCode; signalIndex: number; label: string; cause?: unknown },
109
+ ) {
110
+ super(message, { cause: init.cause });
111
+ this.code = init.code;
112
+ this.signalIndex = init.signalIndex;
113
+ this.label = init.label;
114
+ }
115
+ }
116
+
117
+ /** Your bug, not the file's: you asked for records that do not exist. */
118
+ export class EdfRangeError extends EdfError {
119
+ readonly edfErrorKind = 'range' as const;
120
+ readonly requested: RecordRange;
121
+ readonly available: RecordRange;
122
+
123
+ constructor(
124
+ message: string,
125
+ init: { requested: RecordRange; available: RecordRange; cause?: unknown },
126
+ ) {
127
+ super(message, { cause: init.cause });
128
+ this.requested = init.requested;
129
+ this.available = init.available;
130
+ }
131
+ }
132
+
133
+ /** A `ByteSource` broke its contract: it returned a different number of bytes than asked. */
134
+ export class EdfSourceError extends EdfError {
135
+ readonly edfErrorKind = 'source' as const;
136
+ readonly offset: number;
137
+ readonly requestedLength: number;
138
+ readonly receivedLength: number | undefined;
139
+
140
+ constructor(
141
+ message: string,
142
+ init: {
143
+ offset: number;
144
+ requestedLength: number;
145
+ receivedLength?: number | undefined;
146
+ cause?: unknown;
147
+ },
148
+ ) {
149
+ super(message, { cause: init.cause });
150
+ this.offset = init.offset;
151
+ this.requestedLength = init.requestedLength;
152
+ this.receivedLength = init.receivedLength;
153
+ }
154
+ }
155
+
156
+ /**
157
+ * An allocation was refused before it happened. Float64 physical output is four times the
158
+ * on-disk size for EDF, so without this one honest call can take down a browser tab.
159
+ */
160
+ export class EdfBudgetError extends EdfError {
161
+ readonly edfErrorKind = 'budget' as const;
162
+ readonly requiredBytes: number;
163
+ readonly budgetBytes: number;
164
+ readonly optionName = 'maxMaterializeBytes' as const;
165
+
166
+ constructor(
167
+ message: string,
168
+ init: { requiredBytes: number; budgetBytes: number; cause?: unknown },
169
+ ) {
170
+ super(message, { cause: init.cause });
171
+ this.requiredBytes = init.requiredBytes;
172
+ this.budgetBytes = init.budgetBytes;
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Two or more signals share the requested label. Real files do this: CHB-MIT ships `T8-P8`
178
+ * twice. Silently returning the first is how the wrong channel ends up in a paper.
179
+ */
180
+ export class EdfAmbiguousChannelError extends EdfError {
181
+ readonly edfErrorKind = 'channel' as const;
182
+ readonly label: string;
183
+ readonly matchingIndices: readonly number[];
184
+
185
+ constructor(
186
+ message: string,
187
+ init: { label: string; matchingIndices: readonly number[]; cause?: unknown },
188
+ ) {
189
+ super(message, { cause: init.cause });
190
+ this.label = init.label;
191
+ this.matchingIndices = init.matchingIndices;
192
+ }
193
+ }
194
+
195
+ export class EdfChannelNotFoundError extends EdfError {
196
+ readonly edfErrorKind = 'channel' as const;
197
+ readonly selector: string | number;
198
+ readonly availableLabels: readonly string[];
199
+
200
+ constructor(
201
+ message: string,
202
+ init: { selector: string | number; availableLabels: readonly string[]; cause?: unknown },
203
+ ) {
204
+ super(message, { cause: init.cause });
205
+ this.selector = init.selector;
206
+ this.availableLabels = init.availableLabels;
207
+ }
208
+ }