@scenarist/core 0.3.0 → 0.3.2

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.
@@ -1 +1 @@
1
- {"version":3,"file":"in-memory-state-manager.d.ts","sourceRoot":"","sources":["../../src/adapters/in-memory-state-manager.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,kCAAkC,CAAC;AAMrE;;;;;;GAMG;AACH,qBAAa,oBAAqB,YAAW,YAAY;IACvD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA8C;IAEtE,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO;IAUzC,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,IAAI;IAyBtD,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAI/C,KAAK,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI;IAI3B,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI;IAgB7D,OAAO,CAAC,oBAAoB;IAS5B,OAAO,CAAC,cAAc;IA0CtB,OAAO,CAAC,cAAc;CA6BvB;AAED;;;GAGG;AACH,eAAO,MAAM,0BAA0B,QAAO,YAE7C,CAAC"}
1
+ {"version":3,"file":"in-memory-state-manager.d.ts","sourceRoot":"","sources":["../../src/adapters/in-memory-state-manager.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,kCAAkC,CAAC;AAcrE;;;;;;GAMG;AACH,qBAAa,oBAAqB,YAAW,YAAY;IACvD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA8C;IAEtE,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO;IAUzC,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,IAAI;IAyBtD,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAI/C,KAAK,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI;IAI3B,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI;IAgB7D,OAAO,CAAC,oBAAoB;IAS5B,OAAO,CAAC,cAAc;IA6CtB,OAAO,CAAC,cAAc;CA8BvB;AAED;;;GAGG;AACH,eAAO,MAAM,0BAA0B,QAAO,YAE7C,CAAC"}
@@ -1,5 +1,12 @@
1
1
  const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
2
2
  const isDangerousKey = (key) => DANGEROUS_KEYS.has(key);
3
+ /**
4
+ * Type guard to check if a value is a plain object (Record).
5
+ * Used to properly narrow types after typeof checks.
6
+ */
7
+ const isRecord = (value) => {
8
+ return typeof value === "object" && value !== null && !Array.isArray(value);
9
+ };
3
10
  /**
4
11
  * In-memory implementation of StateManager port.
5
12
  * Fast, single-process state storage for stateful mocks.
@@ -80,21 +87,24 @@ export class InMemoryStateManager {
80
87
  });
81
88
  return;
82
89
  }
90
+ // Get or create nested object for this path segment
83
91
  // eslint-disable-next-line security/detect-object-injection -- Guarded by isDangerousKey and Object.hasOwn
84
92
  const existingValue = Object.hasOwn(obj, key) ? obj[key] : undefined;
85
- if (typeof existingValue !== "object" ||
86
- existingValue === null ||
87
- Array.isArray(existingValue)) {
93
+ // Determine target: use existing record OR create new empty object
94
+ const target = isRecord(existingValue)
95
+ ? existingValue
96
+ : {};
97
+ // If we created a new object, assign it to the parent
98
+ if (!isRecord(existingValue)) {
88
99
  Object.defineProperty(obj, key, {
89
- value: {},
100
+ value: target,
90
101
  writable: true,
91
102
  enumerable: true,
92
103
  configurable: true,
93
104
  });
94
105
  }
95
- // eslint-disable-next-line security/detect-object-injection -- Guarded by isDangerousKey, Object.hasOwn, and Object.defineProperty
96
- const nested = obj[key];
97
- this.setNestedValue(nested, path.slice(1), value);
106
+ // Recurse to next path segment
107
+ this.setNestedValue(target, path.slice(1), value);
98
108
  }
99
109
  getNestedValue(obj, path) {
100
110
  const key = path[0];
@@ -111,7 +121,8 @@ export class InMemoryStateManager {
111
121
  if (path.length === 1) {
112
122
  return value;
113
123
  }
114
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
124
+ // Use type guard for proper type narrowing instead of assertion
125
+ if (!isRecord(value)) {
115
126
  return undefined;
116
127
  }
117
128
  return this.getNestedValue(value, path.slice(1));
@@ -1 +1 @@
1
- {"version":3,"file":"deep-equals.d.ts","sourceRoot":"","sources":["../../src/domain/deep-equals.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,eAAO,MAAM,UAAU,GAAI,GAAG,OAAO,EAAE,GAAG,OAAO,KAAG,OAoEnD,CAAC"}
1
+ {"version":3,"file":"deep-equals.d.ts","sourceRoot":"","sources":["../../src/domain/deep-equals.ts"],"names":[],"mappings":"AAQA;;;;;;;;;GASG;AACH,eAAO,MAAM,UAAU,GAAI,GAAG,OAAO,EAAE,GAAG,OAAO,KAAG,OA+DnD,CAAC"}
@@ -1,3 +1,10 @@
1
+ /**
2
+ * Type guard to check if a value is a plain object (Record).
3
+ * Used to properly narrow types after typeof checks.
4
+ */
5
+ const isRecord = (value) => {
6
+ return typeof value === "object" && value !== null && !Array.isArray(value);
7
+ };
1
8
  /**
2
9
  * Deep equality comparison for values.
3
10
  *
@@ -42,8 +49,8 @@ export const deepEquals = (a, b) => {
42
49
  if (Array.isArray(a) !== Array.isArray(b)) {
43
50
  return false;
44
51
  }
45
- // Handle objects
46
- if (typeof a === "object" && typeof b === "object") {
52
+ // Handle objects (use type guard instead of type assertion)
53
+ if (isRecord(a) && isRecord(b)) {
47
54
  const aKeys = Object.keys(a);
48
55
  const bKeys = Object.keys(b);
49
56
  if (aKeys.length !== bKeys.length) {
@@ -52,6 +52,10 @@ export declare const LogEvents: {
52
52
  readonly STATE_CAPTURED: "state_captured";
53
53
  /** State injected into response */
54
54
  readonly STATE_INJECTED: "state_injected";
55
+ /** State set via afterResponse.setState */
56
+ readonly STATE_SET: "state_set";
57
+ /** State response resolved (condition matched or default) */
58
+ readonly STATE_RESPONSE_RESOLVED: "state_response_resolved";
55
59
  /** Sequence advanced to next response */
56
60
  readonly SEQUENCE_ADVANCED: "sequence_advanced";
57
61
  /** Sequence exhausted all responses */
@@ -1 +1 @@
1
- {"version":3,"file":"log-events.d.ts","sourceRoot":"","sources":["../../src/domain/log-events.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH;;;;;;;;GAQG;AACH,eAAO,MAAM,aAAa;IACxB,oEAAoE;;IAEpE,yCAAyC;;IAEzC,yCAAyC;;IAEzC,kCAAkC;;IAElC,8BAA8B;;CAEtB,CAAC;AAEX;;GAEG;AACH,eAAO,MAAM,SAAS;IAEpB,uCAAuC;;IAEvC,mDAAmD;;IAEnD,mCAAmC;;IAEnC,kCAAkC;;IAIlC,2CAA2C;;IAE3C,4CAA4C;;IAE5C,sCAAsC;;IAEtC,kCAAkC;;IAIlC,mCAAmC;;IAEnC,mCAAmC;;IAInC,yCAAyC;;IAEzC,uCAAuC;;IAIvC,yCAAyC;;IAEzC,8CAA8C;;IAE9C,qCAAqC;;CAE7B,CAAC;AAEX,MAAM,MAAM,QAAQ,GAAG,CAAC,OAAO,SAAS,CAAC,CAAC,MAAM,OAAO,SAAS,CAAC,CAAC"}
1
+ {"version":3,"file":"log-events.d.ts","sourceRoot":"","sources":["../../src/domain/log-events.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH;;;;;;;;GAQG;AACH,eAAO,MAAM,aAAa;IACxB,oEAAoE;;IAEpE,yCAAyC;;IAEzC,yCAAyC;;IAEzC,kCAAkC;;IAElC,8BAA8B;;CAEtB,CAAC;AAEX;;GAEG;AACH,eAAO,MAAM,SAAS;IAEpB,uCAAuC;;IAEvC,mDAAmD;;IAEnD,mCAAmC;;IAEnC,kCAAkC;;IAIlC,2CAA2C;;IAE3C,4CAA4C;;IAE5C,sCAAsC;;IAEtC,kCAAkC;;IAIlC,mCAAmC;;IAEnC,mCAAmC;;IAEnC,2CAA2C;;IAE3C,6DAA6D;;IAI7D,yCAAyC;;IAEzC,uCAAuC;;IAIvC,yCAAyC;;IAEzC,8CAA8C;;IAE9C,qCAAqC;;CAE7B,CAAC;AAEX,MAAM,MAAM,QAAQ,GAAG,CAAC,OAAO,SAAS,CAAC,CAAC,MAAM,OAAO,SAAS,CAAC,CAAC"}
@@ -55,6 +55,10 @@ export const LogEvents = {
55
55
  STATE_CAPTURED: "state_captured",
56
56
  /** State injected into response */
57
57
  STATE_INJECTED: "state_injected",
58
+ /** State set via afterResponse.setState */
59
+ STATE_SET: "state_set",
60
+ /** State response resolved (condition matched or default) */
61
+ STATE_RESPONSE_RESOLVED: "state_response_resolved",
58
62
  // Sequence progression
59
63
  /** Sequence advanced to next response */
60
64
  SEQUENCE_ADVANCED: "sequence_advanced",
@@ -1 +1 @@
1
- {"version":3,"file":"path-extraction.d.ts","sourceRoot":"","sources":["../../src/domain/path-extraction.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAM/D;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,eAAe,GAC1B,SAAS,kBAAkB,EAC3B,MAAM,MAAM,KACX,OAyBF,CAAC"}
1
+ {"version":3,"file":"path-extraction.d.ts","sourceRoot":"","sources":["../../src/domain/path-extraction.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAc/D;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,eAAe,GAC1B,SAAS,kBAAkB,EAC3B,MAAM,MAAM,KACX,OAyBF,CAAC"}
@@ -1,5 +1,12 @@
1
1
  const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
2
2
  const isDangerousKey = (key) => DANGEROUS_KEYS.has(key);
3
+ /**
4
+ * Type guard to check if a value is a plain object (Record).
5
+ * Used to properly narrow types after typeof checks.
6
+ */
7
+ const isRecord = (value) => {
8
+ return typeof value === "object" && value !== null && !Array.isArray(value);
9
+ };
3
10
  /**
4
11
  * Extracts a value from HttpRequestContext based on a path expression.
5
12
  *
@@ -55,8 +62,8 @@ const traversePath = (obj, path) => {
55
62
  if (path.length === 0) {
56
63
  return obj;
57
64
  }
58
- // Guard: Can only traverse objects
59
- if (typeof obj !== "object" || obj === null || Array.isArray(obj)) {
65
+ // Guard: Can only traverse objects - use type guard for proper narrowing
66
+ if (!isRecord(obj)) {
60
67
  return undefined;
61
68
  }
62
69
  const key = path[0];
@@ -68,9 +75,8 @@ const traversePath = (obj, path) => {
68
75
  if (!Object.hasOwn(obj, key)) {
69
76
  return undefined;
70
77
  }
71
- const record = obj;
72
78
  // eslint-disable-next-line security/detect-object-injection -- Key validated by Object.hasOwn and isDangerousKey guard
73
- const value = record[key];
79
+ const value = obj[key];
74
80
  // Recursively traverse remaining path
75
81
  return traversePath(value, path.slice(1));
76
82
  };
@@ -1 +1 @@
1
- {"version":3,"file":"response-selector.d.ts","sourceRoot":"","sources":["../../src/domain/response-selector.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EACV,gBAAgB,EAChB,eAAe,EACf,YAAY,EACZ,MAAM,EACP,MAAM,mBAAmB,CAAC;AAiB3B;;GAEG;AACH,KAAK,6BAA6B,GAAG;IACnC,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,sBAAsB,GACjC,UAAS,6BAAkC,KAC1C,gBAgQF,CAAC"}
1
+ {"version":3,"file":"response-selector.d.ts","sourceRoot":"","sources":["../../src/domain/response-selector.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EACV,gBAAgB,EAChB,eAAe,EACf,YAAY,EACZ,MAAM,EACP,MAAM,mBAAmB,CAAC;AAyB3B;;GAEG;AACH,KAAK,6BAA6B,GAAG;IACnC,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,sBAAsB,GACjC,UAAS,6BAAkC,KAC1C,gBAwQF,CAAC"}
@@ -6,6 +6,13 @@ import { createStateResponseResolver } from "./state-response-resolver.js";
6
6
  import { deepEquals } from "./deep-equals.js";
7
7
  import { noOpLogger } from "../adapters/index.js";
8
8
  import { LogCategories, LogEvents } from "./log-events.js";
9
+ /**
10
+ * Type guard to check if a value is a plain object (Record).
11
+ * Used to properly narrow types after typeof checks.
12
+ */
13
+ const isRecord = (value) => {
14
+ return typeof value === "object" && value !== null && !Array.isArray(value);
15
+ };
9
16
  const SPECIFICITY_RANGES = {
10
17
  MATCH_CRITERIA_BASE: 100,
11
18
  SEQUENCE_FALLBACK: 1,
@@ -72,11 +79,14 @@ export const createResponseSelector = (options = {}) => {
72
79
  continue;
73
80
  }
74
81
  // No match criteria = fallback mock (always matches)
75
- // Log fallback evaluation
82
+ // Log fallback evaluation with response type info for debugging Issue #328
76
83
  logger.debug(LogCategories.MATCHING, LogEvents.MOCK_MATCH_EVALUATED, logContext, {
77
84
  mockIndex,
78
85
  matched: true,
79
86
  hasCriteria: false,
87
+ hasSequence: !!mock.sequence,
88
+ hasStateResponse: !!mock.stateResponse,
89
+ hasResponse: !!mock.response,
80
90
  });
81
91
  // Dynamic response types (sequence, stateResponse) get higher priority than simple responses
82
92
  // This ensures they are selected over simple fallback responses
@@ -105,7 +115,7 @@ export const createResponseSelector = (options = {}) => {
105
115
  specificity,
106
116
  });
107
117
  // Select response (single, sequence, or stateResponse)
108
- const response = selectResponseFromMock(testId, scenarioId, mockIndex, mock, sequenceTracker, stateManager);
118
+ const response = selectResponseFromMock(testId, scenarioId, mockIndex, mock, sequenceTracker, stateManager, logger);
109
119
  if (!response) {
110
120
  return {
111
121
  success: false,
@@ -136,11 +146,15 @@ export const createResponseSelector = (options = {}) => {
136
146
  state: currentState,
137
147
  params: mockWithParams.params || {},
138
148
  };
149
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- applyTemplates preserves structure; input ScenaristResponse → output ScenaristResponse
139
150
  finalResponse = applyTemplates(response, templateData);
140
151
  }
141
152
  // Apply afterResponse.setState to mutate state for subsequent requests
142
153
  if (mock.afterResponse?.setState && stateManager) {
143
154
  stateManager.merge(testId, mock.afterResponse.setState);
155
+ logger.debug(LogCategories.STATE, LogEvents.STATE_SET, logContext, {
156
+ setState: mock.afterResponse.setState,
157
+ });
144
158
  }
145
159
  return { success: true, data: finalResponse };
146
160
  }
@@ -199,9 +213,11 @@ export const createResponseSelector = (options = {}) => {
199
213
  * @param mock - The mock definition
200
214
  * @param sequenceTracker - Optional sequence tracker for Phase 2
201
215
  * @param stateManager - Optional state manager for stateResponse
216
+ * @param logger - Logger for debugging
202
217
  * @returns ScenaristResponse or null if mock has no response type
203
218
  */
204
- const selectResponseFromMock = (testId, scenarioId, mockIndex, mock, sequenceTracker, stateManager) => {
219
+ const selectResponseFromMock = (testId, scenarioId, mockIndex, mock, sequenceTracker, stateManager, logger) => {
220
+ const logContext = { testId, scenarioId };
205
221
  // Phase 2: If mock has a sequence, use sequence tracker
206
222
  if (mock.sequence) {
207
223
  if (!sequenceTracker) {
@@ -222,7 +238,7 @@ const selectResponseFromMock = (testId, scenarioId, mockIndex, mock, sequenceTra
222
238
  }
223
239
  // State-aware response: evaluate conditions against current state
224
240
  if (mock.stateResponse) {
225
- return resolveStateResponse(testId, mock.stateResponse, stateManager);
241
+ return resolveStateResponse(testId, mock.stateResponse, stateManager, logger, logContext);
226
242
  }
227
243
  // Phase 1: Single response
228
244
  if (mock.response) {
@@ -240,18 +256,36 @@ const selectResponseFromMock = (testId, scenarioId, mockIndex, mock, sequenceTra
240
256
  * @param testId - Test ID for state isolation
241
257
  * @param stateResponse - The stateResponse configuration
242
258
  * @param stateManager - Optional state manager for state lookup
259
+ * @param logger - Logger for debugging
260
+ * @param logContext - Context for log messages
243
261
  * @returns The resolved response (matching condition or default)
244
262
  */
245
- const resolveStateResponse = (testId, stateResponse, stateManager) => {
263
+ const resolveStateResponse = (testId, stateResponse, stateManager, logger, logContext) => {
246
264
  // Without stateManager, always return default
247
265
  if (!stateManager) {
266
+ logger.debug(LogCategories.STATE, LogEvents.STATE_RESPONSE_RESOLVED, logContext, {
267
+ result: "default",
268
+ reason: "no_state_manager",
269
+ });
248
270
  return stateResponse.default;
249
271
  }
250
272
  // Get current state for this test
251
273
  const currentState = stateManager.getAll(testId);
252
274
  // Create resolver and evaluate conditions
253
275
  const resolver = createStateResponseResolver();
254
- return resolver.resolveResponse(stateResponse, currentState);
276
+ const response = resolver.resolveResponse(stateResponse, currentState);
277
+ // Log which response was selected and why
278
+ const isDefault = response === stateResponse.default;
279
+ const matchedCondition = isDefault
280
+ ? null
281
+ : stateResponse.conditions.find((c) => resolver.resolveResponse({ default: stateResponse.default, conditions: [c] }, currentState) !== stateResponse.default);
282
+ logger.debug(LogCategories.STATE, LogEvents.STATE_RESPONSE_RESOLVED, logContext, {
283
+ result: isDefault ? "default" : "condition",
284
+ currentState,
285
+ conditionsCount: stateResponse.conditions.length,
286
+ matchedWhen: matchedCondition?.when ?? null,
287
+ });
288
+ return response;
255
289
  };
256
290
  /**
257
291
  * Calculate specificity score for match criteria.
@@ -367,15 +401,14 @@ const matchesState = (stateCriteria, testId, stateManager) => {
367
401
  * Non-string values are converted to strings before matching.
368
402
  */
369
403
  const matchesBody = (requestBody, criteriaBody) => {
370
- // If request has no body, can't match
371
- if (!requestBody || typeof requestBody !== "object") {
404
+ // If request has no body, can't match - use type guard for proper narrowing
405
+ if (!isRecord(requestBody)) {
372
406
  return false;
373
407
  }
374
- const body = requestBody;
375
408
  // Check all required fields exist in request body with matching values
376
409
  for (const [key, criteriaValue] of Object.entries(criteriaBody)) {
377
410
  // eslint-disable-next-line security/detect-object-injection -- Key from Object.entries iteration
378
- const requestValue = body[key];
411
+ const requestValue = requestBody[key];
379
412
  // Convert to string for matching (type coercion like headers/query)
380
413
  const stringValue = requestValue == null ? "" : String(requestValue);
381
414
  if (!matchesValue(stringValue, criteriaValue)) {
@@ -435,21 +468,30 @@ const matchesValue = (requestValue, criteriaValue) => {
435
468
  if (criteriaValue == null) {
436
469
  return requestValue === "";
437
470
  }
438
- const strategyValue = criteriaValue;
439
- if (strategyValue.equals !== undefined) {
440
- return requestValue === String(strategyValue.equals);
471
+ // After ruling out string, RegExp, number, boolean, null - remaining must be object strategy
472
+ if (!isRecord(criteriaValue)) {
473
+ return false;
474
+ }
475
+ if (criteriaValue.equals !== undefined) {
476
+ return requestValue === String(criteriaValue.equals);
441
477
  }
442
- if (strategyValue.contains !== undefined) {
443
- return requestValue.includes(String(strategyValue.contains));
478
+ if (criteriaValue.contains !== undefined) {
479
+ return requestValue.includes(String(criteriaValue.contains));
444
480
  }
445
- if (strategyValue.startsWith !== undefined) {
446
- return requestValue.startsWith(String(strategyValue.startsWith));
481
+ if (criteriaValue.startsWith !== undefined) {
482
+ return requestValue.startsWith(String(criteriaValue.startsWith));
447
483
  }
448
- if (strategyValue.endsWith !== undefined) {
449
- return requestValue.endsWith(String(strategyValue.endsWith));
484
+ if (criteriaValue.endsWith !== undefined) {
485
+ return requestValue.endsWith(String(criteriaValue.endsWith));
450
486
  }
451
- if (strategyValue.regex !== undefined) {
452
- return matchesRegex(requestValue, strategyValue.regex);
487
+ if (criteriaValue.regex !== undefined && isRecord(criteriaValue.regex)) {
488
+ const regex = criteriaValue.regex;
489
+ if (typeof regex.source === "string") {
490
+ return matchesRegex(requestValue, {
491
+ source: regex.source,
492
+ flags: typeof regex.flags === "string" ? regex.flags : undefined,
493
+ });
494
+ }
453
495
  }
454
496
  return false;
455
497
  };
@@ -1 +1 @@
1
- {"version":3,"file":"scenario-manager.d.ts","sourceRoot":"","sources":["../../src/domain/scenario-manager.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,eAAe,EACf,gBAAgB,EAChB,aAAa,EACb,eAAe,EACf,YAAY,EACZ,MAAM,EACP,MAAM,mBAAmB,CAAC;AAwC3B;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,qBAAqB,GAAI,6DAMnC;IACD,QAAQ,EAAE,gBAAgB,CAAC;IAC3B,KAAK,EAAE,aAAa,CAAC;IACrB,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,KAAG,eAwHH,CAAC"}
1
+ {"version":3,"file":"scenario-manager.d.ts","sourceRoot":"","sources":["../../src/domain/scenario-manager.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,eAAe,EACf,gBAAgB,EAChB,aAAa,EACb,eAAe,EACf,YAAY,EACZ,MAAM,EACP,MAAM,mBAAmB,CAAC;AAwC3B;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,qBAAqB,GAAI,6DAMnC;IACD,QAAQ,EAAE,gBAAgB,CAAC;IAC3B,KAAK,EAAE,aAAa,CAAC;IACrB,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,KAAG,eA+HH,CAAC"}
@@ -40,7 +40,13 @@ export const createScenarioManager = ({ registry, store, stateManager, sequenceT
40
40
  const validationResult = ScenaristScenarioSchema.safeParse(definition);
41
41
  if (!validationResult.success) {
42
42
  const errorMessages = validationResult.error.issues.map((err) => `${err.path.join(".")}: ${err.message}`);
43
- const scenarioId = definition?.id || "<unknown>";
43
+ // Extract id safely without type assertion - definition failed validation so type is untrusted
44
+ const scenarioId = typeof definition === "object" &&
45
+ definition !== null &&
46
+ "id" in definition &&
47
+ typeof definition.id === "string"
48
+ ? definition.id
49
+ : "<unknown>";
44
50
  throw createScenarioValidationError(scenarioId, errorMessages);
45
51
  }
46
52
  const existing = registry.get(definition.id);
@@ -2,6 +2,10 @@
2
2
  * Applies templates to a value.
3
3
  * Replaces {{state.key}} and {{params.key}} patterns with actual values.
4
4
  *
5
+ * Note: This function preserves the structure of the input value.
6
+ * Callers passing typed objects (like ScenaristResponse) can safely
7
+ * cast the return value back to the input type.
8
+ *
5
9
  * @param value - Value to apply templates to (string, object, array, or primitive)
6
10
  * @param templateData - Object containing state and params for template replacement.
7
11
  * Can be flat object (backward compatible) or { state: {...}, params: {...} }
@@ -1 +1 @@
1
- {"version":3,"file":"template-replacement.d.ts","sourceRoot":"","sources":["../../src/domain/template-replacement.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,eAAO,MAAM,cAAc,GACzB,OAAO,OAAO,EACd,cAAc,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KACpC,OA+DF,CAAC"}
1
+ {"version":3,"file":"template-replacement.d.ts","sourceRoot":"","sources":["../../src/domain/template-replacement.ts"],"names":[],"mappings":"AAgBA;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,cAAc,GACzB,OAAO,OAAO,EACd,cAAc,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KACpC,OA+DF,CAAC"}
@@ -1,7 +1,25 @@
1
+ /**
2
+ * Type guard to check if a value is a plain object (Record).
3
+ * Used to properly narrow types after typeof checks.
4
+ */
5
+ const isRecord = (value) => {
6
+ return typeof value === "object" && value !== null && !Array.isArray(value);
7
+ };
8
+ /**
9
+ * Security: Check if a property key could cause prototype pollution.
10
+ * @see https://github.com/citypaul/scenarist/security/code-scanning
11
+ */
12
+ const isDangerousKey = (key) => {
13
+ return key === "__proto__" || key === "constructor" || key === "prototype";
14
+ };
1
15
  /**
2
16
  * Applies templates to a value.
3
17
  * Replaces {{state.key}} and {{params.key}} patterns with actual values.
4
18
  *
19
+ * Note: This function preserves the structure of the input value.
20
+ * Callers passing typed objects (like ScenaristResponse) can safely
21
+ * cast the return value back to the input type.
22
+ *
5
23
  * @param value - Value to apply templates to (string, object, array, or primitive)
6
24
  * @param templateData - Object containing state and params for template replacement.
7
25
  * Can be flat object (backward compatible) or { state: {...}, params: {...} }
@@ -86,10 +104,18 @@ const resolveTemplatePath = (templateData, prefix, path) => {
86
104
  if (Array.isArray(current) && segment === "length") {
87
105
  return current.length;
88
106
  }
89
- // Traverse object
90
- const record = current;
91
- // eslint-disable-next-line security/detect-object-injection -- Segment from split() iteration
92
- current = record[segment];
107
+ // Traverse object - use type guard for proper narrowing
108
+ if (!isRecord(current)) {
109
+ return undefined;
110
+ }
111
+ // Security: Prevent prototype pollution attacks
112
+ // @see https://github.com/citypaul/scenarist/security/code-scanning/165
113
+ if (isDangerousKey(segment) || !Object.hasOwn(current, segment)) {
114
+ return undefined;
115
+ }
116
+ // nosemgrep: javascript.lang.security.audit.prototype-pollution.prototype-pollution-loop
117
+ // eslint-disable-next-line security/detect-object-injection -- Segment validated by isDangerousKey and Object.hasOwn checks above
118
+ current = current[segment];
93
119
  // Guard: Return undefined if property doesn't exist
94
120
  if (current === undefined) {
95
121
  return undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scenarist/core",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Internal: Hexagonal architecture core for scenario-based testing with MSW",
5
5
  "author": "Paul Hammond (citypaul) <paul@packsoftware.co.uk>",
6
6
  "license": "MIT",