@monochromatic-dev/module-logger 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 (57) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/LICENSES/GPL-3.0-or-later.txt +674 -0
  3. package/LICENSES/LGPL-3.0-or-later.txt +165 -0
  4. package/README.md +404 -0
  5. package/dist/final/neutral/index.d.mts +673 -0
  6. package/dist/final/neutral/index.mjs +3 -0
  7. package/dist/final/neutral/rolldown-runtime-5duEfhBv.mjs +1 -0
  8. package/dist/final/node/index.d.mts +673 -0
  9. package/dist/final/node/index.mjs +3 -0
  10. package/dist/final/node/rolldown-runtime-5duEfhBv.mjs +1 -0
  11. package/package.json +43 -0
  12. package/src/create-logger.ts +494 -0
  13. package/src/create-logger.unit.test.ts +752 -0
  14. package/src/error-format.ts +43 -0
  15. package/src/index.ts +35 -0
  16. package/src/logger.ts +67 -0
  17. package/src/logger.unit.test.ts +190 -0
  18. package/src/sink/console-control-chars.ts +140 -0
  19. package/src/sink/console-control-chars.unit.test.ts +206 -0
  20. package/src/sink/console.ts +531 -0
  21. package/src/sink/console.unit.test.ts +542 -0
  22. package/src/sink/file.ts +297 -0
  23. package/src/sink/file.unit.test.ts +202 -0
  24. package/src/sink/index.ts +11 -0
  25. package/src/sink/indexed-db-util.ts +96 -0
  26. package/src/sink/indexed-db.browser.test.ts +184 -0
  27. package/src/sink/indexed-db.ts +324 -0
  28. package/src/sink/indexed-db.unit.test.ts +80 -0
  29. package/src/sink/local-storage-key.ts +176 -0
  30. package/src/sink/local-storage-key.unit.test.ts +106 -0
  31. package/src/sink/local-storage-quota.ts +60 -0
  32. package/src/sink/local-storage-quota.unit.test.ts +98 -0
  33. package/src/sink/local-storage-store.ts +368 -0
  34. package/src/sink/local-storage-store.unit.test.ts +329 -0
  35. package/src/sink/local-storage.browser.test.ts +125 -0
  36. package/src/sink/local-storage.ts +182 -0
  37. package/src/sink/local-storage.unit.test.ts +218 -0
  38. package/src/sink/noop.ts +46 -0
  39. package/src/sink/noop.unit.test.ts +47 -0
  40. package/src/sink/opfs.browser.test.ts +84 -0
  41. package/src/sink/opfs.ts +212 -0
  42. package/src/sink/opfs.unit.test.ts +81 -0
  43. package/src/sink/record-buffer.ts +230 -0
  44. package/src/sink/record-buffer.unit.test.ts +288 -0
  45. package/src/sink/session-storage-quota.ts +57 -0
  46. package/src/sink/session-storage-quota.unit.test.ts +98 -0
  47. package/src/sink/session-storage-store.ts +178 -0
  48. package/src/sink/session-storage.browser.test.ts +137 -0
  49. package/src/sink/session-storage.ts +128 -0
  50. package/src/sink/session-storage.unit.test.ts +527 -0
  51. package/src/sink/web-storage-quota-error.ts +43 -0
  52. package/src/sink/web-storage-quota-error.unit.test.ts +55 -0
  53. package/src/sink/web-storage-runtime.ts +49 -0
  54. package/src/startup.unit.test.ts +232 -0
  55. package/src/tagged.ts +74 -0
  56. package/src/tagged.unit.test.ts +211 -0
  57. package/src/types.ts +78 -0
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Internal logger error reporting helpers.
3
+ *
4
+ * Logger internals cannot report failures through the logger itself without
5
+ * risking recursion, so these helpers format caught values and write directly
6
+ * to the host console.
7
+ *
8
+ * @module
9
+ */
10
+
11
+ import { caughtValueText, } from '@monochromatic-dev/module-caught-value/ts';
12
+
13
+ /**
14
+ * Reports a logger-internal caught value without going back through logger
15
+ * sinks, formatting it via {@link caughtValueText}.
16
+ *
17
+ * @param context - Human-readable operation that caught the value.
18
+ *
19
+ * @param error - Caught value to include in the diagnostic.
20
+ *
21
+ * @mutates error - `caughtValueText` may invoke string-conversion hooks.
22
+ *
23
+ * @example
24
+ * ```ts
25
+ * reportLoggerInternalError({
26
+ * context: 'console sink verify failed',
27
+ * error: new Error('blocked'),
28
+ * });
29
+ * ```
30
+ */
31
+ export function reportLoggerInternalError(
32
+ {
33
+ context,
34
+ error,
35
+ }: {
36
+ readonly context: string;
37
+ readonly error: unknown;
38
+ },
39
+ ): void {
40
+ console.warn(
41
+ `logger internal error: ${context}: ${caughtValueText(error,)}`,
42
+ );
43
+ }
package/src/index.ts ADDED
@@ -0,0 +1,35 @@
1
+ export {
2
+ createLogger,
3
+ DEFAULT_FLUSH_DEADLINE_MS,
4
+ } from './create-logger.ts';
5
+ export {
6
+ initPromise,
7
+ logger,
8
+ } from './logger.ts';
9
+ export * as sinks from './sink/index.ts';
10
+ export { tagged, } from './tagged.ts';
11
+
12
+ //region Internal seams
13
+ // Underscore-prefixed re-exports let unit tests exercise internal modules
14
+ // through the built artifact (the `require-eventual-artifact` rule) without
15
+ // widening the documented API; they are not part of the public contract.
16
+ export { neutralizeControlCharacters as _neutralizeControlCharacters, } from './sink/console-control-chars.ts';
17
+ export {
18
+ buildLogKey as _buildLogKey,
19
+ compareLogKeys as _compareLogKeys,
20
+ parseLogKey as _parseLogKey,
21
+ } from './sink/local-storage-key.ts';
22
+ export { detectLocalStorageQuotaChars as _detectLocalStorageQuotaChars, } from './sink/local-storage-quota.ts';
23
+ export { createLocalStorageStore as _createLocalStorageStore, } from './sink/local-storage-store.ts';
24
+ export { createRecordBuffer as _createRecordBuffer, } from './sink/record-buffer.ts';
25
+ export { detectSessionStorageQuotaChars as _detectSessionStorageQuotaChars, } from './sink/session-storage-quota.ts';
26
+ export { isQuotaExceededError as _isQuotaExceededError, } from './sink/web-storage-quota-error.ts';
27
+ //endregion Internal seams
28
+ export type {
29
+ Level,
30
+ Logger,
31
+ LogRecord,
32
+ Sink,
33
+ SinkFlush,
34
+ Verify,
35
+ } from './types.ts';
package/src/logger.ts ADDED
@@ -0,0 +1,67 @@
1
+ import { createLogger, } from './create-logger.ts';
2
+ import { createConsoleSink, } from './sink/console.ts';
3
+ import { createFileSink, } from './sink/file.ts';
4
+ import { createIndexedDbSink, } from './sink/indexed-db.ts';
5
+ import { createLocalStorageSink, } from './sink/local-storage.ts';
6
+ import { createSessionStorageSink, } from './sink/session-storage.ts';
7
+ import type {
8
+ Logger,
9
+ Sink,
10
+ } from './types.ts';
11
+
12
+ /**
13
+ * Default sink backends to attempt, in priority order. Each runtime keeps
14
+ * only the sinks whose `verify` confirms its backend: {@link createConsoleSink}
15
+ * everywhere, {@link createIndexedDbSink} in browsers,
16
+ * {@link createSessionStorageSink} wherever web storage round-trips (browsers,
17
+ * Node 22+, Deno), {@link createLocalStorageSink} wherever `localStorage`
18
+ * round-trips (browsers, Deno, Node launched with `--localstorage-file`),
19
+ * {@link createFileSink} under Node. The noop sink is intentionally absent:
20
+ * the console sink verifies wherever `console` and `queueMicrotask` exist,
21
+ * so the default logger has a backend in every supported runtime, and a
22
+ * custom `createLogger` whose sinks all fail verification surfaces the
23
+ * "No logging backends available" error instead of silently discarding.
24
+ * The OPFS sink is exported
25
+ * but no longer a default: its stream stages writes until a close that a
26
+ * crash never performs, so IndexedDB holds the persistent-browser slot; see
27
+ * `DECISIONS.md`.
28
+ */
29
+ const defaultSinks: readonly Sink[] = [
30
+ createConsoleSink(),
31
+ createIndexedDbSink(),
32
+ createSessionStorageSink(),
33
+ createLocalStorageSink(),
34
+ createFileSink(),
35
+ ];
36
+
37
+ /**
38
+ * Default multi-sink logger plus its eager readiness promise, built by
39
+ * applying {@link createLogger} to {@link defaultSinks}.
40
+ */
41
+ const {
42
+ initPromise: defaultInitPromise,
43
+ logger: defaultLogger,
44
+ } = createLogger({ sinks: defaultSinks, },);
45
+
46
+ /**
47
+ * Eager readiness promise. Consumers do not need to await this before logging;
48
+ * {@link Logger.flush} awaits it internally, and startup records replay to
49
+ * async sinks as they become available.
50
+ */
51
+ export const initPromise: Promise<void> = defaultInitPromise;
52
+
53
+ /**
54
+ * Multi-sink logger that writes to all available backends.
55
+ * Startup records replay to async sinks that verify after the log call.
56
+ * Log calls throw only when initialization proves no backend is available,
57
+ * which the console sink prevents in every supported runtime.
58
+ *
59
+ * @example
60
+ * ```ts
61
+ * import { logger, } from '\@monochromatic-dev/module-logger/logger';
62
+ *
63
+ * logger.error('unexpected shutdown',);
64
+ * await logger.flush();
65
+ * ```
66
+ */
67
+ export const logger: Logger = defaultLogger;
@@ -0,0 +1,190 @@
1
+ import {
2
+ describe,
3
+ expect,
4
+ it,
5
+ } from '@monochromatic-dev/module-test/ts';
6
+ import {
7
+ logger,
8
+ } from '@monochromatic-dev/module-logger';
9
+
10
+ await describe({
11
+ name: logger.constructor.name,
12
+ children: [
13
+ it({
14
+ name: 'logger has all six log level methods',
15
+ fn: async () => {
16
+ expect(typeof logger.trace,).toBe('function',);
17
+ expect(typeof logger.debug,).toBe('function',);
18
+ expect(typeof logger.info,).toBe('function',);
19
+ expect(typeof logger.warn,).toBe('function',);
20
+ expect(typeof logger.error,).toBe('function',);
21
+ expect(typeof logger.fatal,).toBe('function',);
22
+ },
23
+ },),
24
+
25
+ it({
26
+ name: 'trace method accepts string message',
27
+ fn: async () => {
28
+ expect(() => {
29
+ logger.trace('test trace message',);
30
+ },)
31
+ .not
32
+ .toThrow();
33
+ },
34
+ },),
35
+
36
+ it({
37
+ name: 'debug method accepts string message',
38
+ fn: async () => {
39
+ expect(() => {
40
+ logger.debug('test debug message',);
41
+ },)
42
+ .not
43
+ .toThrow();
44
+ },
45
+ },),
46
+
47
+ it({
48
+ name: 'info method accepts string message',
49
+ fn: async () => {
50
+ expect(() => {
51
+ logger.info('test info message',);
52
+ },)
53
+ .not
54
+ .toThrow();
55
+ },
56
+ },),
57
+
58
+ it({
59
+ name: 'warn method accepts string message',
60
+ fn: async () => {
61
+ expect(() => {
62
+ logger.warn('test warn message',);
63
+ },)
64
+ .not
65
+ .toThrow();
66
+ },
67
+ },),
68
+
69
+ it({
70
+ name: 'error method accepts string message',
71
+ fn: async () => {
72
+ expect(() => {
73
+ logger.error('test error message',);
74
+ },)
75
+ .not
76
+ .toThrow();
77
+ },
78
+ },),
79
+
80
+ it({
81
+ name: 'fatal method accepts string message',
82
+ fn: async () => {
83
+ expect(() => {
84
+ logger.fatal('test fatal message',);
85
+ },)
86
+ .not
87
+ .toThrow();
88
+ },
89
+ },),
90
+
91
+ it({
92
+ name: 'logs with empty string message',
93
+ fn: async () => {
94
+ expect(() => {
95
+ logger.info('',);
96
+ },)
97
+ .not
98
+ .toThrow();
99
+ },
100
+ },),
101
+
102
+ it({
103
+ name: 'logs with unicode message',
104
+ fn: async () => {
105
+ expect(() => {
106
+ logger.info('Hello 世界 🌍',);
107
+ },)
108
+ .not
109
+ .toThrow();
110
+ },
111
+ },),
112
+
113
+ it({
114
+ name: 'logs with multiline message',
115
+ fn: async () => {
116
+ expect(() => {
117
+ logger.info('line1\nline2\nline3',);
118
+ },)
119
+ .not
120
+ .toThrow();
121
+ },
122
+ },),
123
+
124
+ it({
125
+ name: 'logs with special characters',
126
+ fn: async () => {
127
+ expect(() => {
128
+ logger.info('Special: <script>alert("xss")</script>',);
129
+ },)
130
+ .not
131
+ .toThrow();
132
+ },
133
+ },),
134
+
135
+ it({
136
+ name: 'logs with JSON-like content',
137
+ fn: async () => {
138
+ expect(() => {
139
+ logger.info('{"key": "value", "count": 42}',);
140
+ },)
141
+ .not
142
+ .toThrow();
143
+ },
144
+ },),
145
+
146
+ it({
147
+ name: 'handles rapid successive logs',
148
+ fn: async () => {
149
+ expect(() => {
150
+ const RAPID_LOG_COUNT = 100;
151
+ for (let logIndex = 0; logIndex < RAPID_LOG_COUNT; logIndex++)
152
+ logger.debug(`rapid log ${logIndex}`,);
153
+ },)
154
+ .not
155
+ .toThrow();
156
+ },
157
+ },),
158
+
159
+ it({
160
+ name: 'flush is a callable method returning a promise',
161
+ fn: async () => {
162
+ expect(typeof logger.flush,)
163
+ .toBe('function',);
164
+ const result = logger.flush();
165
+ expect(result instanceof Promise,)
166
+ .toBe(true,);
167
+ await result;
168
+ },
169
+ },),
170
+
171
+ it({
172
+ name: 'flush drains buffered console records',
173
+ fn: async () => {
174
+ // The console sink batches on microtasks; after a sync burst
175
+ // of logs, flush should resolve after the console has actually
176
+ // received the records. We cannot easily assert the count here
177
+ // without re-spying (the default logger owns its sinks), so this
178
+ // test only verifies that flush resolves after at least one
179
+ // microtask tick rather than returning an already-settled promise
180
+ // that misses the flush.
181
+ logger.info('pre-flush 1',);
182
+ logger.info('pre-flush 2',);
183
+ logger.info('pre-flush 3',);
184
+ await expect(logger.flush(),)
185
+ .resolves
186
+ .toBeUndefined();
187
+ },
188
+ },),
189
+ ],
190
+ },);
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Console-bound text crosses a syntax boundary: a terminal interprets C0 and
3
+ * C1 control characters as commands (clear screen, set title, move cursor,
4
+ * write clipboard). Log messages can carry attacker-influenced text, so the
5
+ * console sink neutralizes every control character except newline and tab
6
+ * before the text reaches `console.*` or `process.stderr`.
7
+ *
8
+ * @module
9
+ */
10
+
11
+ /**
12
+ * First code unit above the C0 control range; everything below it except
13
+ * newline and tab is neutralized.
14
+ */
15
+ const C0_CONTROL_LIMIT = 0x20;
16
+
17
+ /**
18
+ * Newline stays literal: multi-line messages (stack traces) are a core use.
19
+ */
20
+ const NEWLINE_CODE_UNIT = 0x0A;
21
+
22
+ /**
23
+ * Tab stays literal: indentation in multi-line messages is harmless.
24
+ */
25
+ const TAB_CODE_UNIT = 0x09;
26
+
27
+ /**
28
+ * DEL sits alone above the printable ASCII range and is a control character.
29
+ */
30
+ const DELETE_CODE_UNIT = 0x7F;
31
+
32
+ /**
33
+ * First code unit of the C1 control range (8-bit CSI, OSC, and friends).
34
+ */
35
+ const C1_CONTROL_START = 0x80;
36
+
37
+ /**
38
+ * Last code unit of the C1 control range.
39
+ */
40
+ const C1_CONTROL_END = 0x9F;
41
+
42
+ /**
43
+ * Radix for the hexadecimal digits inside a `\uXXXX` escape.
44
+ */
45
+ const HEX_RADIX = 16;
46
+
47
+ /**
48
+ * Digit count of a `\uXXXX` escape, zero-padded on the left.
49
+ */
50
+ const UNICODE_ESCAPE_WIDTH = 4;
51
+
52
+ /**
53
+ * Reports whether one UTF-16 code unit is a control character the console
54
+ * sink must neutralize.
55
+ *
56
+ * @param codeUnit - UTF-16 code unit read from the message.
57
+ *
58
+ * @returns Whether the code unit is a C0 control other than newline and tab,
59
+ * DEL, or a C1 control.
60
+ *
61
+ * @example
62
+ * ```ts
63
+ * isNeutralizedControl(0x1B); // true (ESC)
64
+ * isNeutralizedControl(0x0A); // false (newline stays)
65
+ * ```
66
+ */
67
+ function isNeutralizedControl(codeUnit: number,): boolean {
68
+ if (codeUnit < C0_CONTROL_LIMIT)
69
+ return (codeUnit !== NEWLINE_CODE_UNIT) && (codeUnit !== TAB_CODE_UNIT);
70
+
71
+ if (codeUnit === DELETE_CODE_UNIT)
72
+ return true;
73
+
74
+ return (codeUnit >= C1_CONTROL_START) && (codeUnit <= C1_CONTROL_END);
75
+ }
76
+
77
+ /**
78
+ * Renders one code unit as a `\uXXXX` escape with uppercase hex digits (the
79
+ * repository's escape-case convention) so the attempted control stays
80
+ * visible for forensics instead of vanishing.
81
+ *
82
+ * @param codeUnit - UTF-16 code unit to escape.
83
+ *
84
+ * @returns Six-character escape such as `\u001B`.
85
+ *
86
+ * @example
87
+ * ```ts
88
+ * escapeCodeUnit(0x1B); // '\\u001B'
89
+ * ```
90
+ */
91
+ function escapeCodeUnit(codeUnit: number,): string {
92
+ return `\\u${
93
+ codeUnit
94
+ .toString(HEX_RADIX,)
95
+ .toUpperCase()
96
+ .padStart(
97
+ UNICODE_ESCAPE_WIDTH,
98
+ '0',
99
+ )
100
+ }`;
101
+ }
102
+
103
+ /**
104
+ * Neutralizes terminal control characters in console-bound text. One linear
105
+ * pass over the code points: each neutralized control becomes a `\uXXXX`
106
+ * escape, everything else is copied through, and newline and tab pass
107
+ * untouched. Well-formed and malformed escape sequences get no
108
+ * special treatment because the introducer byte itself is neutralized, so a
109
+ * trailing lone ESC, an unterminated OSC, and a nested ESC all lose their
110
+ * teeth the same way.
111
+ *
112
+ * @param text - Message text destined for `console.*` or `process.stderr`.
113
+ *
114
+ * @returns Text with every neutralized control rendered as `\uXXXX`.
115
+ *
116
+ * @example
117
+ * ```ts
118
+ * neutralizeControlCharacters('title:\u001B]0;x\u0007 ok\n\tnext');
119
+ * // => 'title:\\u001B]0;x\\u0007 ok\n\tnext'
120
+ * ```
121
+ */
122
+ export function neutralizeControlCharacters(text: string,): string {
123
+ /**
124
+ * Output pieces in input order: each code point either verbatim or as its
125
+ * escape, joined once at the end so no per-character string rebuild occurs.
126
+ */
127
+ const pieces: string[] = [];
128
+ for (const character of text) {
129
+ /**
130
+ * Leading code unit of this iteration element. String iteration walks
131
+ * code points, so a surrogate pair arrives as one two-unit string whose
132
+ * lead surrogate is never a control, and a lone surrogate passes the
133
+ * same way.
134
+ */
135
+ // oxlint-disable-next-line unicorn/prefer-code-point -- Classifier reads the lead code unit on purpose; controls below U+00A0 never sit inside a surrogate pair, so code-point decoding adds nothing.
136
+ const codeUnit = character.charCodeAt(0,);
137
+ pieces.push(isNeutralizedControl(codeUnit,) ? escapeCodeUnit(codeUnit,) : character,);
138
+ }
139
+ return pieces.join('',);
140
+ }
@@ -0,0 +1,206 @@
1
+ import {
2
+ describe,
3
+ expect,
4
+ it,
5
+ } from '@monochromatic-dev/module-test/ts';
6
+ import {
7
+ _neutralizeControlCharacters as neutralizeControlCharacters,
8
+ } from '@monochromatic-dev/module-logger';
9
+
10
+ /**
11
+ * Adversarial inputs at the terminal boundary paired with the exact output
12
+ * the neutralizer must produce. Each case names the attack or the malformed
13
+ * shape it pins.
14
+ */
15
+ const BOUNDARY_CASES: readonly {
16
+ readonly name: string;
17
+ readonly input: string;
18
+ readonly expected: string;
19
+ }[] = [
20
+ {
21
+ name: 'OSC title-set sequence',
22
+ input: 'title:\u001B]0;PWNED\u0007 ok',
23
+ expected: 'title:\\u001B]0;PWNED\\u0007 ok',
24
+ },
25
+ {
26
+ name: 'CSI clear-screen sequence',
27
+ input: 'clear:\u001B[2J',
28
+ expected: 'clear:\\u001B[2J',
29
+ },
30
+ {
31
+ name: 'SGR color sequence (no allowlist)',
32
+ input: '\u001B[31mred\u001B[0m',
33
+ expected: '\\u001B[31mred\\u001B[0m',
34
+ },
35
+ {
36
+ name: 'trailing lone ESC',
37
+ input: 'tail\u001B',
38
+ expected: 'tail\\u001B',
39
+ },
40
+ {
41
+ name: 'ESC [ with no final byte',
42
+ input: 'open\u001B[',
43
+ expected: 'open\\u001B[',
44
+ },
45
+ {
46
+ name: 'unterminated OSC',
47
+ input: '\u001B]2;never closed',
48
+ expected: '\\u001B]2;never closed',
49
+ },
50
+ {
51
+ name: 'nested ESC inside a sequence',
52
+ input: '\u001B[\u001B[2J',
53
+ expected: '\\u001B[\\u001B[2J',
54
+ },
55
+ {
56
+ name: '8-bit C1 CSI',
57
+ input: 'c1:\u009B2J',
58
+ expected: 'c1:\\u009B2J',
59
+ },
60
+ {
61
+ name: 'DEL',
62
+ input: 'del:\u007F',
63
+ expected: 'del:\\u007F',
64
+ },
65
+ {
66
+ name: 'NUL',
67
+ input: 'nul:\u0000end',
68
+ expected: 'nul:\\u0000end',
69
+ },
70
+ {
71
+ name: 'carriage return (line overwrite)',
72
+ input: 'real\rfake',
73
+ expected: 'real\\u000Dfake',
74
+ },
75
+ {
76
+ name: 'backspace',
77
+ input: 'ab\bc',
78
+ expected: 'ab\\u0008c',
79
+ },
80
+ ];
81
+
82
+ /**
83
+ * Inputs the neutralizer must return unchanged.
84
+ */
85
+ const PASSTHROUGH_CASES: readonly {
86
+ readonly name: string;
87
+ readonly input: string;
88
+ }[] = [
89
+ {
90
+ name: 'plain ASCII',
91
+ input: 'server started on port 3000',
92
+ },
93
+ {
94
+ name: 'empty string',
95
+ input: '',
96
+ },
97
+ {
98
+ name: 'newline and tab',
99
+ input: 'line one\n\tindented two\n',
100
+ },
101
+ {
102
+ name: 'non-ASCII text and emoji',
103
+ input: 'héllo wörld 🚀 日本語',
104
+ },
105
+ {
106
+ name: 'lone surrogate',
107
+ input: 'x\uD83Dy',
108
+ },
109
+ {
110
+ name: 'format specifiers',
111
+ input: '%s %d %j %%',
112
+ },
113
+ {
114
+ name: 'code point just above the C1 range',
115
+ input: 'nbsp:\u00A0end',
116
+ },
117
+ ];
118
+
119
+ /**
120
+ * One past the last C1 code unit; the exhaustive sweep covers every code unit
121
+ * below it.
122
+ */
123
+ const CONTROL_SWEEP_LENGTH = 0xA0;
124
+
125
+ /**
126
+ * Reports whether an output code unit is still a control the boundary forbids.
127
+ *
128
+ * @param codeUnit - UTF-16 code unit read from neutralizer output.
129
+ *
130
+ * @returns Whether the code unit should have been neutralized.
131
+ */
132
+ function isForbiddenControl(codeUnit: number,): boolean {
133
+ if (codeUnit < 0x20)
134
+ return (codeUnit !== 0x0A) && (codeUnit !== 0x09);
135
+ if (codeUnit === 0x7F)
136
+ return true;
137
+ return (codeUnit >= 0x80) && (codeUnit <= 0x9F);
138
+ }
139
+
140
+ await describe({
141
+ name: neutralizeControlCharacters.name,
142
+ children: [
143
+ //region Neutralized controls
144
+
145
+ ...BOUNDARY_CASES.map(function mapBoundaryCase(boundaryCase,) {
146
+ return it({
147
+ name: `neutralizes ${boundaryCase.name}`,
148
+ fn: async () => {
149
+ expect(neutralizeControlCharacters(boundaryCase.input,),)
150
+ .toBe(boundaryCase.expected,);
151
+ },
152
+ },);
153
+ },),
154
+
155
+ it({
156
+ name: 'output never contains a neutralized control after the pass',
157
+ fn: async () => {
158
+ /**
159
+ * Every code unit from NUL through U+009F, in one string.
160
+ */
161
+ const allControls = Array.from(
162
+ { length: CONTROL_SWEEP_LENGTH, },
163
+ function toChar(
164
+ _unused,
165
+ index,
166
+ ) {
167
+ return String.fromCodePoint(index,);
168
+ },
169
+ )
170
+ .join('',);
171
+ /**
172
+ * Neutralized sweep; only newline and tab may survive as controls.
173
+ */
174
+ const output = neutralizeControlCharacters(allControls,);
175
+ /**
176
+ * Output code units that are still forbidden controls; must stay empty.
177
+ */
178
+ const leaked: number[] = [];
179
+ for (const character of output) {
180
+ // oxlint-disable-next-line unicorn/prefer-code-point -- The sweep classifies lead code units below U+00A0, which never sit inside a surrogate pair.
181
+ const codeUnit = character.charCodeAt(0,);
182
+ if (isForbiddenControl(codeUnit,))
183
+ leaked.push(codeUnit,);
184
+ }
185
+ expect(leaked,)
186
+ .toEqual([],);
187
+ },
188
+ },),
189
+
190
+ //endregion Neutralized controls
191
+
192
+ //region Passthrough
193
+
194
+ ...PASSTHROUGH_CASES.map(function mapPassthroughCase(passthroughCase,) {
195
+ return it({
196
+ name: `leaves ${passthroughCase.name} untouched`,
197
+ fn: async () => {
198
+ expect(neutralizeControlCharacters(passthroughCase.input,),)
199
+ .toBe(passthroughCase.input,);
200
+ },
201
+ },);
202
+ },),
203
+
204
+ //endregion Passthrough
205
+ ],
206
+ },);