@williamzujkowski/live-coding-music-mcp 4.0.0 → 4.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 (53) hide show
  1. package/README.md +202 -705
  2. package/dist/AudioAnalyzer.js +1294 -91
  3. package/dist/PatternStore.js +276 -23
  4. package/dist/StrudelController.js +388 -56
  5. package/dist/server/resources.js +27 -20
  6. package/dist/server/server.js +384 -76
  7. package/dist/server/tools/ai.js +124 -91
  8. package/dist/server/tools/analysis.js +168 -59
  9. package/dist/server/tools/capture.js +135 -24
  10. package/dist/server/tools/compose.js +26 -26
  11. package/dist/server/tools/diagnostics.js +20 -3
  12. package/dist/server/tools/editor.js +60 -6
  13. package/dist/server/tools/generate.js +38 -15
  14. package/dist/server/tools/history.js +30 -8
  15. package/dist/server/tools/playback.js +2 -1
  16. package/dist/server/tools/session.js +38 -6
  17. package/dist/server/tools/storage.js +179 -8
  18. package/dist/server/tools/transform.js +127 -43
  19. package/dist/server/tools/types.js +209 -14
  20. package/dist/services/AudioCaptureService.js +302 -33
  21. package/dist/services/AudioExportService.js +318 -0
  22. package/dist/services/BrowserOnlyFunctions.js +149 -0
  23. package/dist/services/EventDensityProbe.js +324 -0
  24. package/dist/services/GeminiService.js +221 -36
  25. package/dist/services/IsolatedEngineRunner.js +308 -0
  26. package/dist/services/IsolatedStrudelEngine.js +135 -0
  27. package/dist/services/LocalPatternEngine.js +16 -0
  28. package/dist/services/MIDIExportService.js +765 -71
  29. package/dist/services/MIDIImportService.js +474 -0
  30. package/dist/services/MusicTheory.js +85 -13
  31. package/dist/services/PatternGenerator.js +168 -85
  32. package/dist/services/PatternSandbox.js +359 -0
  33. package/dist/services/SessionManager.js +338 -65
  34. package/dist/services/StrudelEngine.js +218 -12
  35. package/dist/services/StrudelEngineHelpers.js +24 -8
  36. package/dist/services/StyleRegistry.js +204 -0
  37. package/dist/services/Transferable.js +114 -0
  38. package/dist/services/ai/AiTransport.js +56 -0
  39. package/dist/services/ai/AudioMeasurements.js +155 -0
  40. package/dist/services/ai/CliTransport.js +256 -0
  41. package/dist/services/engineChild.js +83 -0
  42. package/dist/services/engineChildPath.js +28 -0
  43. package/dist/utils/CategorisedError.js +59 -0
  44. package/dist/utils/ErrorRecovery.js +130 -110
  45. package/dist/utils/InputValidator.js +55 -7
  46. package/dist/utils/PatternValidator.js +136 -17
  47. package/dist/utils/SafePath.js +112 -0
  48. package/dist/utils/ServerConfig.js +248 -0
  49. package/dist/utils/TableLookup.js +41 -0
  50. package/dist/utils/Tempo.js +143 -0
  51. package/package.json +17 -5
  52. package/dist/services/__mocks__/StrudelEngine.js +0 -157
  53. package/dist/types/index.js +0 -1
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Locates the isolated-engine child entrypoint (#307).
3
+ *
4
+ * This is its own module, reached by dynamic import, for one reason:
5
+ * `import.meta` is a **parse error** under this project's Jest setup,
6
+ * which runs the codebase as CommonJS (there is no
7
+ * `--experimental-vm-modules`). Any module that so much as mentions
8
+ * `import.meta` becomes unimportable from a test, and the runner has to
9
+ * stay testable. Tests pass an explicit child path, so they never load
10
+ * this file. Same reason `scripts/` is the only other place using it.
11
+ */
12
+ import { fileURLToPath } from 'node:url';
13
+ import path from 'node:path';
14
+ /**
15
+ * Resolves the child entrypoint as a sibling of this module, matching the
16
+ * extension we are ourselves running as: `.js` from `dist/`, `.ts` under
17
+ * `tsx`.
18
+ *
19
+ * @returns Absolute path to the child, and whether it needs a TS loader
20
+ */
21
+ export function resolveChildEntrypoint() {
22
+ const here = fileURLToPath(import.meta.url);
23
+ const needsTsx = here.endsWith('.ts');
24
+ return {
25
+ childPath: path.join(path.dirname(here), needsTsx ? 'engineChild.ts' : 'engineChild.js'),
26
+ needsTsx,
27
+ };
28
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Errors that carry their own category (#382).
3
+ *
4
+ * `categorizeError` decides retryability by matching phrases in the
5
+ * message — `invalid`, `must be`, `not found`, `timed out`. Anything it
6
+ * does not recognise falls through to `internal`, which is not
7
+ * retryable and reads to an agent as "the server is broken".
8
+ *
9
+ * An audit of every `throw new Error(...)` in `src/` put 47 messages in
10
+ * that bucket, and at least eleven of them were plainly the caller's
11
+ * input:
12
+ *
13
+ * Steps cannot exceed 256, got 512
14
+ * Hits (9) cannot exceed steps (8)
15
+ * Number of sounds must match number of patterns
16
+ * MIDI input too large (900000 > 512000 bytes)
17
+ *
18
+ * None contains a phrase the matcher looks for, and the difference
19
+ * decides whether an agent edits its argument or gives up.
20
+ *
21
+ * Growing the matcher is the treadmill this replaces: "cannot exceed"
22
+ * would be the fourth phrase patched in after the fact, after the
23
+ * `'timed out'` vs `'timeout'` fix and the rate-limit class of #380.
24
+ * A thrower that knows what kind of failure it has should say so.
25
+ */
26
+ /** The caller passed something that cannot be accepted. Not retryable; change the argument. */
27
+ export class ValidationError extends Error {
28
+ constructor(message) {
29
+ super(message);
30
+ this.name = 'ValidationError';
31
+ }
32
+ }
33
+ /**
34
+ * The request is well-formed but the system is not in a state to serve
35
+ * it, and the message names the action that would fix that — create a
36
+ * session, destroy one, run init.
37
+ */
38
+ export class BusinessError extends Error {
39
+ constructor(message) {
40
+ super(message);
41
+ this.name = 'BusinessError';
42
+ }
43
+ }
44
+ /**
45
+ * A failure that may not recur: the model returned something
46
+ * unparseable, a network hiccup, a service that was briefly unwell.
47
+ *
48
+ * The distinction that matters to a caller is whether trying again could
49
+ * help. `No JSON found in response` means the model produced prose where
50
+ * a JSON block was asked for — the next attempt often does not, and the
51
+ * message contains no word the phrase matcher recognises, so it landed
52
+ * in `internal` and told the caller not to bother.
53
+ */
54
+ export class TransientError extends Error {
55
+ constructor(message) {
56
+ super(message);
57
+ this.name = 'TransientError';
58
+ }
59
+ }
@@ -6,7 +6,51 @@ import { Logger } from './Logger.js';
6
6
  export class ErrorRecovery {
7
7
  logger;
8
8
  errorHistory = new Map();
9
+ /**
10
+ * Failures that a retry or fallback went on to rescue.
11
+ *
12
+ * Kept apart from `errorHistory` because success calls
13
+ * clearErrorHistory, which used to erase exactly the case an operator
14
+ * most wants to see — "writes are flaky but recovering on retry 2".
15
+ * Measured before this: a run that failed once then succeeded
16
+ * reported `{}`, indistinguishable from a run with no trouble at all
17
+ * (#286).
18
+ */
19
+ recoveredHistory = new Map();
20
+ /**
21
+ * Operations that actually feed these statistics. Only
22
+ * handlePatternWrite routes through executeWithRetry today, so an
23
+ * empty map meant "nothing instrumented reported trouble", not "the
24
+ * system is healthy" — and the Record<string, …> shape implied
25
+ * coverage that did not exist. Reported explicitly so a reader can
26
+ * tell the two apart (#286).
27
+ */
28
+ /**
29
+ * Operations `getErrorStats` seeds with an explicit zero row.
30
+ *
31
+ * This named 'Pattern Write' — which nothing calls: its only path,
32
+ * `handlePatternWrite`, is reached solely from
33
+ * `writePatternWithValidation`, and nothing in `src/server` calls
34
+ * that. So diagnostics carried a permanent all-zero row for dead code
35
+ * while omitting the one operation that does run (#445).
36
+ *
37
+ * 'Browser Init' is the live one, from `StrudelController.ts:199`
38
+ * (#315). A zero row for it is the useful kind: it says the retry
39
+ * path exists and has not fired, which is exactly what an operator
40
+ * wants to know before a failure rather than after.
41
+ */
42
+ static INSTRUMENTED_OPERATIONS = ['Browser Init'];
9
43
  ERROR_WINDOW = 60000; // 1 minute window for error tracking
44
+ /**
45
+ * Operations this instance has handled, successfully or not.
46
+ *
47
+ * The seed list above says which paths EXIST; this says which have
48
+ * RUN. A clean run has no error and no recovery to record, so without
49
+ * it a successful operation left no row and looked identical to one
50
+ * nothing ever watched — the ambiguity #288 removed for the seeded
51
+ * names and left everywhere else.
52
+ */
53
+ seenOperations = new Set();
10
54
  constructor() {
11
55
  this.logger = new Logger();
12
56
  }
@@ -17,18 +61,47 @@ export class ErrorRecovery {
17
61
  * @param strategy - Recovery strategy configuration
18
62
  * @returns Result of the operation
19
63
  */
64
+ /**
65
+ * Note on reachability: the only production entry point into this
66
+ * class is `handlePatternWrite`, which calls this. `executeWithRetry`
67
+ * and `clearErrorHistory` are public because they are the seam the
68
+ * retry tests drive directly, not because anything else calls them.
69
+ *
70
+ * Seven methods that nothing called at all — handleBrowserInit,
71
+ * handleNetworkOperation, createCircuitBreaker, isFrequentlyFailing,
72
+ * executeWithTimeout, executeWithRetryAndTimeout and
73
+ * clearAllErrorHistory — were removed in #309. They were fully tested,
74
+ * which is how the file reported 100% coverage while most of it was
75
+ * unreachable, and how the docs could describe an `ErrorRecovery.withRetry`
76
+ * that never existed without anything noticing.
77
+ *
78
+ * Browser init and strudel.cc requests still have no retry. That gap
79
+ * is real and is tracked separately: reviving deleted machinery
80
+ * deserves the scrutiny of new code, not a restore from history.
81
+ */
20
82
  async executeWithRetry(operation, operationName, strategy = {
21
83
  maxRetries: 3,
22
84
  retryDelay: 1000,
23
85
  exponentialBackoff: true
24
86
  }) {
87
+ // Recorded on entry: an operation that ran and succeeded should
88
+ // report an explicit zero rather than no row at all (#288's
89
+ // distinction, which only the seeded names used to get).
90
+ this.seenOperations.add(operationName);
25
91
  let lastError = null;
26
92
  for (let attempt = 0; attempt <= strategy.maxRetries; attempt++) {
27
93
  try {
28
94
  this.logger.debug(`Executing ${operationName} (attempt ${attempt + 1}/${strategy.maxRetries + 1})`);
29
95
  const result = await operation();
30
- // Success - clear error history for this operation
31
- this.clearErrorHistory(operationName);
96
+ // Success. The failures that preceded it still happened, so
97
+ // move them to the recovered log rather than dropping them —
98
+ // dropping them is what made recovered flakiness invisible
99
+ // (#286). recordRecovery clears the outstanding bucket itself.
100
+ this.recordRecovery(operationName);
101
+ // The error bucket only — not `clearErrorHistory`, which also
102
+ // forgets that the operation ran and would make every success
103
+ // erase its own zero row (#445).
104
+ this.errorHistory.delete(operationName);
32
105
  return result;
33
106
  }
34
107
  catch (error) {
@@ -37,9 +110,13 @@ export class ErrorRecovery {
37
110
  this.logger.warn(`${operationName} failed (attempt ${attempt + 1}/${strategy.maxRetries + 1})`, { error: error.message });
38
111
  // Don't retry on last attempt
39
112
  if (attempt < strategy.maxRetries) {
40
- const delay = strategy.exponentialBackoff
113
+ const base = strategy.exponentialBackoff
41
114
  ? strategy.retryDelay * Math.pow(2, attempt)
42
115
  : strategy.retryDelay;
116
+ // Full-jitter-lite: half the computed delay plus a random half.
117
+ const delay = strategy.jitter
118
+ ? Math.round(base * (0.5 + Math.random() * 0.5))
119
+ : base;
43
120
  this.logger.debug(`Waiting ${delay}ms before retry`);
44
121
  await this.sleep(delay);
45
122
  }
@@ -51,7 +128,10 @@ export class ErrorRecovery {
51
128
  if (strategy.fallbackAction) {
52
129
  this.logger.info(`Executing fallback for ${operationName}`);
53
130
  try {
54
- return await strategy.fallbackAction();
131
+ const fallbackResult = await strategy.fallbackAction();
132
+ // The fallback rescued it, so these failures are recovered too.
133
+ this.recordRecovery(operationName);
134
+ return fallbackResult;
55
135
  }
56
136
  catch (fallbackError) {
57
137
  this.logger.error(`Fallback failed for ${operationName}`, fallbackError);
@@ -59,47 +139,6 @@ export class ErrorRecovery {
59
139
  }
60
140
  throw new Error(`${operationName} failed after ${strategy.maxRetries + 1} attempts: ${lastError?.message}`);
61
141
  }
62
- /**
63
- * Wraps an operation with timeout protection
64
- * @param operation - Async function to execute
65
- * @param timeout - Timeout in milliseconds
66
- * @param operationName - Name for error messages
67
- * @returns Result of the operation
68
- */
69
- async executeWithTimeout(operation, timeout, operationName) {
70
- return Promise.race([
71
- operation(),
72
- this.createTimeout(timeout, operationName)
73
- ]);
74
- }
75
- /**
76
- * Combines retry logic with timeout protection
77
- * @param operation - Async function to execute
78
- * @param operationName - Name for logging
79
- * @param timeout - Timeout per attempt in milliseconds
80
- * @param strategy - Recovery strategy
81
- * @returns Result of the operation
82
- */
83
- async executeWithRetryAndTimeout(operation, operationName, timeout, strategy) {
84
- return this.executeWithRetry(() => this.executeWithTimeout(operation, timeout, operationName), operationName, strategy);
85
- }
86
- /**
87
- * Gracefully handles browser initialization errors
88
- * @param initFunction - Browser initialization function
89
- * @returns Initialization result
90
- */
91
- async handleBrowserInit(initFunction) {
92
- return this.executeWithRetryAndTimeout(initFunction, 'Browser Initialization', 30000, // 30 second timeout
93
- {
94
- maxRetries: 2,
95
- retryDelay: 2000,
96
- exponentialBackoff: true,
97
- fallbackAction: async () => {
98
- this.logger.warn('Browser initialization failed, suggesting headless mode');
99
- return 'Browser initialization failed. Try setting headless:true in config.json';
100
- }
101
- });
102
- }
103
142
  /**
104
143
  * Handles pattern write errors with validation
105
144
  * @param writeFunction - Pattern write function
@@ -119,19 +158,6 @@ export class ErrorRecovery {
119
158
  }
120
159
  });
121
160
  }
122
- /**
123
- * Checks if an operation is experiencing repeated failures
124
- * @param operationName - Name of operation to check
125
- * @param threshold - Number of errors to consider "frequent"
126
- * @returns True if operation is failing frequently
127
- */
128
- isFrequentlyFailing(operationName, threshold = 3) {
129
- const errors = this.errorHistory.get(operationName) || [];
130
- const now = Date.now();
131
- // Count errors within the time window
132
- const recentErrors = errors.filter(timestamp => now - timestamp < this.ERROR_WINDOW);
133
- return recentErrors.length >= threshold;
134
- }
135
161
  /**
136
162
  * Gets error statistics for monitoring
137
163
  * @returns Error statistics by operation
@@ -139,13 +165,26 @@ export class ErrorRecovery {
139
165
  getErrorStats() {
140
166
  const stats = {};
141
167
  const now = Date.now();
142
- this.errorHistory.forEach((timestamps, operation) => {
143
- const recentErrors = timestamps.filter(ts => now - ts < this.ERROR_WINDOW);
168
+ const recent = (ts) => ts.filter(t => now - t < this.ERROR_WINDOW);
169
+ // Seed with every instrumented operation so an all-zero row is
170
+ // distinguishable from an operation nothing ever watched. `{}` used
171
+ // to mean both.
172
+ const operations = new Set([
173
+ ...ErrorRecovery.INSTRUMENTED_OPERATIONS,
174
+ ...this.seenOperations,
175
+ ...this.errorHistory.keys(),
176
+ ...this.recoveredHistory.keys(),
177
+ ]);
178
+ for (const operation of operations) {
179
+ const failed = recent(this.errorHistory.get(operation) ?? []);
180
+ const rescued = recent(this.recoveredHistory.get(operation) ?? []);
144
181
  stats[operation] = {
145
- count: recentErrors.length,
146
- lastError: recentErrors.length > 0 ? new Date(Math.max(...recentErrors)) : null
182
+ count: failed.length,
183
+ lastError: failed.length > 0 ? new Date(Math.max(...failed)) : null,
184
+ recovered: rescued.length,
185
+ lastRecovery: rescued.length > 0 ? new Date(Math.max(...rescued)) : null,
147
186
  };
148
- });
187
+ }
149
188
  return stats;
150
189
  }
151
190
  /**
@@ -153,13 +192,38 @@ export class ErrorRecovery {
153
192
  * @param operationName - Operation to clear
154
193
  */
155
194
  clearErrorHistory(operationName) {
195
+ // Forgotten entirely, including that it ever ran — "clear this
196
+ // operation's history" that left a zero row behind would be a
197
+ // different promise than this method's name makes.
198
+ //
199
+ // The success path inside `executeWithRetry` does NOT use this: it
200
+ // clears the error bucket directly, because an operation that just
201
+ // succeeded has run and should report an explicit zero rather than
202
+ // vanish (#288, #445).
203
+ this.seenOperations.delete(operationName);
156
204
  this.errorHistory.delete(operationName);
157
205
  }
158
206
  /**
159
- * Clears all error history
207
+ * Moves an operation's recorded failures into the recovered log.
208
+ *
209
+ * Called when a retry or fallback rescues the operation, so the
210
+ * failures survive the clearErrorHistory that follows.
211
+ *
212
+ * @param operationName - Name of the operation
160
213
  */
161
- clearAllErrorHistory() {
162
- this.errorHistory.clear();
214
+ recordRecovery(operationName) {
215
+ const failures = this.errorHistory.get(operationName);
216
+ if (!failures || failures.length === 0)
217
+ return;
218
+ const now = Date.now();
219
+ const existing = this.recoveredHistory.get(operationName) ?? [];
220
+ const merged = [...existing, ...failures]
221
+ .filter(ts => now - ts < this.ERROR_WINDOW);
222
+ this.recoveredHistory.set(operationName, merged);
223
+ // A move, not a copy: a failure belongs in exactly one bucket, or
224
+ // the fallback path reports the same three failures as both
225
+ // outstanding and rescued.
226
+ this.errorHistory.delete(operationName);
163
227
  }
164
228
  /**
165
229
  * Records an error occurrence
@@ -173,19 +237,6 @@ export class ErrorRecovery {
173
237
  const recentErrors = errors.filter(ts => now - ts < this.ERROR_WINDOW);
174
238
  this.errorHistory.set(operationName, recentErrors);
175
239
  }
176
- /**
177
- * Creates a timeout promise
178
- * @param ms - Timeout in milliseconds
179
- * @param operationName - Name for error message
180
- * @returns Promise that rejects after timeout
181
- */
182
- createTimeout(ms, operationName) {
183
- return new Promise((_, reject) => {
184
- setTimeout(() => {
185
- reject(new Error(`${operationName} timed out after ${ms}ms`));
186
- }, ms);
187
- });
188
- }
189
240
  /**
190
241
  * Sleep utility
191
242
  * @param ms - Milliseconds to sleep
@@ -214,35 +265,4 @@ export class ErrorRecovery {
214
265
  });
215
266
  return simplified;
216
267
  }
217
- /**
218
- * Handles network-related errors
219
- * @param operation - Operation that may fail due to network
220
- * @param operationName - Name for logging
221
- * @returns Operation result
222
- */
223
- async handleNetworkOperation(operation, operationName) {
224
- return this.executeWithRetry(operation, operationName, {
225
- maxRetries: 5,
226
- retryDelay: 2000,
227
- exponentialBackoff: true,
228
- fallbackAction: async () => {
229
- throw new Error(`Network operation ${operationName} failed - check internet connection`);
230
- }
231
- });
232
- }
233
- /**
234
- * Creates a circuit breaker for operations that fail frequently
235
- * @param operation - Operation to protect
236
- * @param operationName - Name for tracking
237
- * @param threshold - Number of failures before circuit opens
238
- * @returns Protected operation
239
- */
240
- createCircuitBreaker(operation, operationName, threshold = 5) {
241
- return async () => {
242
- if (this.isFrequentlyFailing(operationName, threshold)) {
243
- throw new Error(`Circuit breaker open for ${operationName} - operation disabled due to repeated failures`);
244
- }
245
- return operation();
246
- };
247
- }
248
268
  }
@@ -9,6 +9,7 @@
9
9
  *
10
10
  * All methods throw descriptive errors for invalid inputs.
11
11
  */
12
+ import { ValidationError } from './CategorisedError.js';
12
13
  export class InputValidator {
13
14
  // Valid scale names from MusicTheory service
14
15
  static VALID_SCALES = [
@@ -108,11 +109,11 @@ export class InputValidator {
108
109
  throw new Error('Steps must be a positive integer');
109
110
  }
110
111
  if (steps > 256) {
111
- throw new Error(`Steps cannot exceed 256, got ${steps}`);
112
+ throw new ValidationError(`Steps cannot exceed 256, got ${steps}`);
112
113
  }
113
114
  // Validate relationship
114
115
  if (hits > steps) {
115
- throw new Error(`Hits (${hits}) cannot exceed steps (${steps})`);
116
+ throw new ValidationError(`Hits (${hits}) cannot exceed steps (${steps})`);
116
117
  }
117
118
  }
118
119
  /**
@@ -182,7 +183,9 @@ export class InputValidator {
182
183
  *
183
184
  * @example
184
185
  * InputValidator.validateRootNote('C'); // ✓ Valid
185
- * InputValidator.validateRootNote('f#'); // ✓ Valid (normalized to F#)
186
+ * InputValidator.validateRootNote('f#'); // ✓ Valid — accepted, NOT
187
+ * // normalized; returns void
188
+ * // and the caller keeps 'f#' (#491)
186
189
  * InputValidator.validateRootNote('X'); // ✗ Throws: Invalid root note
187
190
  */
188
191
  static validateRootNote(note) {
@@ -201,14 +204,45 @@ export class InputValidator {
201
204
  throw new Error(`Invalid root note: ${note}. Valid notes: ${this.VALID_ROOT_NOTES.join(', ')}`);
202
205
  }
203
206
  }
207
+ /**
208
+ * Validates that a value is a plain JavaScript-style identifier.
209
+ *
210
+ * Use this wherever a caller-supplied name is interpolated into a
211
+ * RegExp or into generated pattern source. `effect({action:"remove"})`
212
+ * built `new RegExp(`\\.${args.effect}\\(...`)` from a raw argument,
213
+ * so `effect: "(a+)+Z"` against a crafted pattern blocked the event
214
+ * loop for 25 seconds — the server stops answering stdio entirely (#236).
215
+ *
216
+ * @param value - Value to validate
217
+ * @param fieldName - Field name for the error message
218
+ * @throws {Error} When the value is not a plain identifier
219
+ *
220
+ * @example
221
+ * InputValidator.validateIdentifier('lpf', 'effect'); // ✓ Valid
222
+ * InputValidator.validateIdentifier('(a+)+Z', 'effect'); // ✗ Throws
223
+ *
224
+ * @nist si-10 "Information input validation"
225
+ */
226
+ static validateIdentifier(value, fieldName) {
227
+ if (typeof value !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
228
+ throw new Error(`Invalid ${fieldName}: ${String(value)}. ` +
229
+ 'Must be a plain identifier (letters, digits and underscore, not starting with a digit).');
230
+ }
231
+ }
204
232
  /**
205
233
  * Validates string length to prevent resource exhaustion
206
234
  *
207
235
  * @param str - String to validate
208
236
  * @param fieldName - Field name for error messages
209
- * @param maxLength - Maximum allowed length (default: 1000)
210
- * @param allowEmpty - Whether to allow empty strings (default: false for identifiers, true for content)
211
- * @throws {Error} When string exceeds maximum length or is not a string
237
+ * @param maxLength - Maximum allowed length, in UTF-16 code units
238
+ * (default: 1000). Must be a finite non-negative number.
239
+ * @param allowEmpty - Whether to allow empty strings. The default is
240
+ * `true`; callers that need an identifier pass `false` explicitly.
241
+ * This said "false for identifiers, true for content", describing a
242
+ * default that varies by field name and never existed — the default
243
+ * is one value and it is `true` (#491).
244
+ * @throws {Error} When string exceeds maximum length, is not a string,
245
+ * or `maxLength` is not a usable bound
212
246
  *
213
247
  * @example
214
248
  * InputValidator.validateStringLength('hello', 'pattern', 100); // ✓ Valid
@@ -220,11 +254,25 @@ export class InputValidator {
220
254
  if (typeof str !== 'string') {
221
255
  throw new Error(`${fieldName} must be a string`);
222
256
  }
257
+ // A bound that cannot bound anything is a caller bug, not a limit.
258
+ //
259
+ // `str.length > NaN` is false, as is every comparison with NaN, so
260
+ // a NaN maxLength silently disabled the check entirely: a
261
+ // million-character string was accepted by the function whose whole
262
+ // purpose is preventing resource exhaustion. `maxLength` itself was
263
+ // never validated (#491).
264
+ if (!Number.isFinite(maxLength) || maxLength < 0) {
265
+ throw new Error(`${fieldName} length check misconfigured: maxLength must be a finite ` +
266
+ `non-negative number, got ${String(maxLength)}`);
267
+ }
223
268
  if (!allowEmpty && str.trim().length === 0) {
224
269
  throw new Error(`${fieldName} cannot be empty`);
225
270
  }
271
+ // UTF-16 code units, which is what `.length` counts and what a
272
+ // resource bound should count. The message used to say "characters",
273
+ // so a single emoji was reported as 2 of them (#491).
226
274
  if (str.length > maxLength) {
227
- throw new Error(`${fieldName} too long (max ${maxLength} characters, got ${str.length})`);
275
+ throw new Error(`${fieldName} too long (max ${maxLength} UTF-16 code units, got ${str.length})`);
228
276
  }
229
277
  }
230
278
  /**