@ecoma-io/archkeep 0.18.1 → 0.20.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.
@@ -0,0 +1,642 @@
1
+ /**
2
+ * Scenario Evaluation: the second architecture-intelligence capability
3
+ * (`docs/doctrine/scenario-evaluation.md`).
4
+ *
5
+ * Given a hypothetical change description (the "scenario"), applies it to a
6
+ * real workspace graph and re-runs the deterministic impact-analysis path to
7
+ * produce a current-versus-scenario comparison. Every output field carries a
8
+ * `virtual: true` / `notAuthoritative` marker — a scenario is never a real
9
+ * verdict and never enters canonical history.
10
+ *
11
+ * ## What it evaluates
12
+ *
13
+ * For the MVP, a scenario describes **dependency changes**:
14
+ *
15
+ * - `dependency_added`: adds an edge from `source` to `target`.
16
+ * - `dependency_removed`: removes an edge from `source` to `target`.
17
+ *
18
+ * Each scenario is evaluated against a **base graph** (the current workspace
19
+ * graph). The scenario's would-be graph is derived by applying the changes,
20
+ * then the deterministic impact path is re-run. The result is compared against
21
+ * the current impact to produce a delta.
22
+ *
23
+ * ## Design constraints
24
+ *
25
+ * - Read-only: no workspace mutation, no canonical history write.
26
+ * - Deterministic: two runs over the same base and scenario produce identical
27
+ * output.
28
+ * - Reuses existing primitives: `computeImpact`, `computeImpactConstraints`.
29
+ * - Every consequence is labelled `virtual: true` / `notAuthoritative`.
30
+ * - States its own limits: any unevaluated consequence is named.
31
+ *
32
+ * @module
33
+ */
34
+ import { computeImpact } from "./impact.mjs";
35
+ import { computeImpactConstraints } from "./edge-constraints.mjs";
36
+ import { readAdrContext } from "./adr.mjs";
37
+ import { hasAuthority, resolveDecisionRef, stripAdrPrefix } from "../governance/adr-registry.mjs";
38
+ import { isComboDepConstraint } from "../rules/tags.mjs";
39
+ import { execSync } from "node:child_process";
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Scenario types
43
+ // ---------------------------------------------------------------------------
44
+
45
+ /** The supported scenario change types. */
46
+ export const SCENARIO_CHANGE_TYPES = Object.freeze(["dependency_added", "dependency_removed"]);
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Input schema types
50
+ // ---------------------------------------------------------------------------
51
+
52
+ /**
53
+ * @typedef {object} DependencyChange
54
+ * @property {"dependency_added"|"dependency_removed"} type
55
+ * @property {string} source The source project of the dependency.
56
+ * @property {string} target The target project of the dependency.
57
+ */
58
+
59
+ /**
60
+ * @typedef {object} ScenarioInput
61
+ * @property {string} [base] Optional git revision for attribution. When not
62
+ * provided, resolved from `git rev-parse HEAD`.
63
+ * @property {DependencyChange[]} changes The hypothetical changes to evaluate.
64
+ */
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Output types
68
+ // ---------------------------------------------------------------------------
69
+ /**
70
+ * @typedef {object} ScenarioEvaluation
71
+ * @property {boolean} virtual Always true — a scenario is never authoritative.
72
+ * @property {boolean} notAuthoritative Always true — mirrors `virtual`.
73
+ * @property {string} project The target project being evaluated.
74
+ * @property {object} base The base graph information.
75
+ * @property {string} base.revision The git revision or snapshot identity.
76
+ * @property {boolean} base.attributed Whether the base is a real, verifiable revision.
77
+ * @property {string} base.provenance How the base was determined (user-provided, auto-resolved, or unverifiable).
78
+ * @property {string[]} changes The change descriptions that were applied.
79
+ * @property {string[]|undefined} refused Changes that could not be applied, if any.
80
+ * @property {object} current The current impact for the target project.
81
+ * @property {object} scenario The would-be impact after applying the changes.
82
+ * @property {object} delta What would change.
83
+ * @property {object} [evidenceChain] The provenance chain: base → changes → re-evaluated → delta.
84
+ * @property {string} evidenceChain.baseRevision The revision the scenario started from.
85
+ * @property {string[]} evidenceChain.appliedChanges The changes applied to the base.
86
+ * @property {string} evidenceChain.currentState The state before applying changes ("current").
87
+ * @property {string} evidenceChain.scenarioState The state after applying changes ("scenario").
88
+ * @property {object} evidenceChain.delta The computed differences.
89
+ * @property {object} [governanceImpact] Governance re-evaluation results.
90
+ * @property {boolean} governanceImpact.findingsReEvaluated Whether findings were re-evaluated.
91
+ * @property {boolean} governanceImpact.debtReEvaluated Whether debt was re-evaluated.
92
+ * @property {boolean} governanceImpact.governanceComplete Whether all governance data was provided.
93
+ * @property {number} governanceImpact.scenarioFindingsCount Number of findings in the scenario state.
94
+ * @property {number} governanceImpact.scenarioDebtCount Number of debt entries in the scenario state.
95
+ * @property {boolean} complete Whether the evaluation could be fully completed.
96
+ * @property {string[]} notes Caveats about the evaluation.
97
+ */
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Graph manipulation
101
+ // ---------------------------------------------------------------------------
102
+
103
+ /**
104
+ * Deep-clones the graph's nodes and dependencies for mutation.
105
+ *
106
+ * @param {object} graph The project graph: `{nodes, dependencies}`.
107
+ * @returns {{nodes: object, dependencies: object}}
108
+ */
109
+ function cloneGraph(graph) {
110
+ const nodes = { ...graph.nodes };
111
+ const dependencies = {};
112
+ for (const [source, edges] of Object.entries(graph.dependencies)) {
113
+ dependencies[source] = edges.map((e) => ({ ...e }));
114
+ }
115
+ return { nodes, dependencies };
116
+ }
117
+
118
+ /**
119
+ * Applies a scenario's changes to a graph, producing a would-be graph.
120
+ *
121
+ * @param {object} graph The base graph to apply changes to.
122
+ * @param {DependencyChange[]} changes The hypothetical changes.
123
+ * @returns {{graph: object, applied: string[], refused: string[]}}
124
+ */
125
+ function applyChanges(graph, changes) {
126
+ const cloned = cloneGraph(graph);
127
+ const applied = [];
128
+ const refused = [];
129
+
130
+ for (const change of changes) {
131
+ if (!SCENARIO_CHANGE_TYPES.includes(change.type)) {
132
+ refused.push(`unsupported change type: "${change.type}"`);
133
+ continue;
134
+ }
135
+
136
+ if (change.type === "dependency_added") {
137
+ // Validate that source and target exist in the graph
138
+ if (!Object.hasOwn(cloned.nodes, change.source)) {
139
+ refused.push(`cannot add dependency: source project "${change.source}" not in graph`);
140
+ continue;
141
+ }
142
+ if (!Object.hasOwn(cloned.nodes, change.target)) {
143
+ refused.push(`cannot add dependency: target project "${change.target}" not in graph`);
144
+ continue;
145
+ }
146
+
147
+ // Check if edge already exists
148
+ const existing = cloned.dependencies[change.source] ?? [];
149
+ if (existing.some((e) => e.target === change.target)) {
150
+ applied.push(`dependency already exists: ${change.source} → ${change.target}`);
151
+ continue;
152
+ }
153
+
154
+ // Add the edge
155
+ if (!cloned.dependencies[change.source]) {
156
+ cloned.dependencies[change.source] = [];
157
+ }
158
+ cloned.dependencies[change.source].push({
159
+ target: change.target,
160
+ type: "static",
161
+ source: change.source,
162
+ });
163
+ applied.push(`added dependency: ${change.source} → ${change.target}`);
164
+ }
165
+
166
+ if (change.type === "dependency_removed") {
167
+ const existing = cloned.dependencies[change.source] ?? [];
168
+ const idx = existing.findIndex((e) => e.target === change.target);
169
+ if (idx === -1) {
170
+ refused.push(
171
+ `cannot remove dependency: no edge from "${change.source}" to "${change.target}"`,
172
+ );
173
+ continue;
174
+ }
175
+ existing.splice(idx, 1);
176
+ applied.push(`removed dependency: ${change.source} → ${change.target}`);
177
+ }
178
+ }
179
+
180
+ return { graph: cloned, applied, refused };
181
+ }
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // Decision impact (reuses impact-statement's buildDecisionImpact)
185
+ // ---------------------------------------------------------------------------
186
+
187
+ /**
188
+ * Builds decision impact for the scenario's would-be state.
189
+ *
190
+ * @param {string} root Workspace root path.
191
+ * @param {object[]} constraintImpact Per-dependent constraint analysis.
192
+ * @param {object} config The loaded boundary config.
193
+ * @returns {{decisions: object[], unresolvedDecisionRefs: string[]}|null}
194
+ */
195
+ function buildScenarioDecisionImpact(root, constraintImpact, config) {
196
+ if (!constraintImpact || !config?.depConstraints) {
197
+ return { decisions: [], unresolvedDecisionRefs: [] };
198
+ }
199
+
200
+ // Collect unique decisionRefs ONLY from constraint rows that are actually
201
+ // AFFECTED by the scenario change — rows that govern edges from impacted
202
+ // dependents. A decisionRef in the config is not enough.
203
+ const seenRefs = new Set();
204
+ const affectedRefs = [];
205
+
206
+ // Build a set of all constraint rows that appear in constraintImpact,
207
+ // using identity matching (the rows are the actual config row objects).
208
+ const activeRows = new Set(constraintImpact.flatMap((entry) => entry.constraintRows));
209
+
210
+ for (const row of config.depConstraints) {
211
+ if (!row.decisionRef) continue;
212
+ if (activeRows.has(row) && !seenRefs.has(row.decisionRef)) {
213
+ seenRefs.add(row.decisionRef);
214
+ affectedRefs.push(row.decisionRef);
215
+ }
216
+ }
217
+
218
+ if (affectedRefs.length === 0) {
219
+ return { decisions: [], unresolvedDecisionRefs: [] };
220
+ }
221
+
222
+ let adrContext;
223
+ try {
224
+ adrContext = readAdrContext(root);
225
+ } catch {
226
+ return { decisions: [], unresolvedDecisionRefs: [...affectedRefs].sort() };
227
+ }
228
+
229
+ const { byId, knownFitness } = adrContext;
230
+ const unresolvedDecisionRefs = [];
231
+ const decisions = [];
232
+
233
+ for (const ref of affectedRefs) {
234
+ const resolution = resolveDecisionRef(byId, knownFitness, ref);
235
+ if (resolution === "unknown") {
236
+ unresolvedDecisionRefs.push(ref);
237
+ continue;
238
+ }
239
+ if (resolution === "fitness") {
240
+ decisions.push({ id: ref, kind: "fitness", resolution: "known" });
241
+ continue;
242
+ }
243
+ const record = byId.get(stripAdrPrefix(ref));
244
+ decisions.push({
245
+ id: record.id,
246
+ kind: "adr",
247
+ status: record.status,
248
+ hasAuthority: hasAuthority(record.status),
249
+ });
250
+ }
251
+
252
+ return {
253
+ decisions: decisions.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)),
254
+ unresolvedDecisionRefs: [...new Set(unresolvedDecisionRefs)].sort(),
255
+ };
256
+ }
257
+
258
+ /**
259
+ * Builds evolution alignment for the scenario.
260
+ *
261
+ * @param {string} projectName The target project.
262
+ *
263
+ * @param {object[]} [constraintImpact]
264
+ * @param {string[]} [resolvedDecisions]
265
+ * @returns {{projects: string[], boundaries: string[], constraints: string[], decisions: string[]}}
266
+ */
267
+ function buildScenarioEvolutionAlignment(projectName, impact, constraintImpact, resolvedDecisions) {
268
+ const affectedProjects = [projectName, ...impact.dependents];
269
+ const affectedConstraints = [];
270
+ const affectedBoundaries = [];
271
+
272
+ if (constraintImpact) {
273
+ for (const entry of constraintImpact) {
274
+ // Collect edge identities for each affected boundary
275
+ for (const edge of entry.edges) {
276
+ const edgeId = `${entry.project}>${edge.target}:${edge.type}`;
277
+ if (!affectedBoundaries.includes(edgeId)) {
278
+ affectedBoundaries.push(edgeId);
279
+ }
280
+ }
281
+ // Collect constraint row labels using same format as buildEvolutionAlignment
282
+ for (const row of entry.constraintRows) {
283
+ const label = isComboDepConstraint(row)
284
+ ? `allSourceTags:${row.allSourceTags.join(",")}`
285
+ : `sourceTag:${row.sourceTag}`;
286
+ if (!affectedConstraints.includes(label)) {
287
+ affectedConstraints.push(label);
288
+ }
289
+ }
290
+ }
291
+ }
292
+
293
+ return {
294
+ projects: [...new Set(affectedProjects)].sort(),
295
+ boundaries: affectedBoundaries.sort(),
296
+ constraints: affectedConstraints.sort(),
297
+ decisions: resolvedDecisions ? [...new Set(resolvedDecisions)].sort() : [],
298
+ };
299
+ }
300
+
301
+ /**
302
+ * Computes the delta between current and scenario.
303
+ *
304
+ * @param {object} current Current impact.
305
+ * @param {object} scenario Scenario impact.
306
+ * @returns {{dependentsAdded: string[], dependentsRemoved: string[],
307
+ * constraintsChanged: boolean, decisionsChanged: boolean}}
308
+ */
309
+ function computeDelta(current, scenario) {
310
+ const currentDeps = new Set(current.impact.dependents ?? []);
311
+ const scenarioDeps = new Set(scenario.impact.dependents ?? []);
312
+
313
+ const dependentsAdded = [...scenarioDeps].filter((d) => !currentDeps.has(d)).sort();
314
+ const dependentsRemoved = [...currentDeps].filter((d) => !scenarioDeps.has(d)).sort();
315
+
316
+ const constraintsChanged =
317
+ JSON.stringify(current.constraintImpact ?? []) !==
318
+ JSON.stringify(scenario.constraintImpact ?? []);
319
+
320
+ const decisionsChanged =
321
+ JSON.stringify(current.decisionImpact ?? []) !== JSON.stringify(scenario.decisionImpact ?? []);
322
+
323
+ return {
324
+ dependentsAdded,
325
+ dependentsRemoved,
326
+ constraintsChanged,
327
+ decisionsChanged,
328
+ };
329
+ }
330
+ /**
331
+ * Resolves the base revision for a scenario evaluation.
332
+ *
333
+ * When the user provides a `base` string, it is used as-is and marked as
334
+ * attributed. When no base is provided, we attempt to resolve from
335
+ * `git rev-parse HEAD`. If that fails, we report the gap rather than
336
+ * fabricating a revision.
337
+ *
338
+ * @param {string} root The workspace root.
339
+ * @param {string|undefined} userBase The user-provided base (optional).
340
+ * @returns {{revision: string, attributed: boolean, provenance: string}}
341
+ */
342
+ function resolveBaseRevision(root, userBase) {
343
+ if (typeof userBase === "string" && userBase.length > 0) {
344
+ return {
345
+ revision: userBase,
346
+ attributed: true,
347
+ provenance: "user-provided",
348
+ };
349
+ }
350
+
351
+ // Attempt to resolve from git
352
+ try {
353
+ const revision = execSync("git rev-parse HEAD", {
354
+ cwd: root,
355
+ encoding: "utf8",
356
+ timeout: 5000,
357
+ }).trim();
358
+ if (revision && revision.length === 40) {
359
+ return {
360
+ revision,
361
+ attributed: true,
362
+ provenance: "auto-resolved: git rev-parse HEAD",
363
+ };
364
+ }
365
+ } catch {
366
+ // Fall through to unverifiable
367
+ }
368
+
369
+ return {
370
+ revision: "(unattributed workspace)",
371
+ attributed: false,
372
+ provenance: "unverifiable — git rev-parse HEAD failed or not a git repository",
373
+ };
374
+ }
375
+
376
+ /**
377
+ * Evaluates a scenario against the current workspace.
378
+ *
379
+ * @param {string} projectName The target project.
380
+ * @param {object} commandContext The resolved command context.
381
+ * @param {ScenarioInput} scenarioInput The scenario description.
382
+ * @param {object|null} [config] The loaded boundary config.
383
+ * @param {object} [options] Optional data for governance integration.
384
+ * @param {object[]|null} [options.findings] Pre-computed findings for
385
+ * governance re-evaluation on the hypothetical graph.
386
+ * @param {object[]|null} [options.debt] Pre-computed debt entries for
387
+ * governance re-evaluation.
388
+ * @returns {ScenarioEvaluation}
389
+ */
390
+ export function evaluateScenario(
391
+ projectName,
392
+ commandContext,
393
+ scenarioInput,
394
+ config = null,
395
+ options = {},
396
+ ) {
397
+ const { root, graph } = commandContext;
398
+ const { findings: availableFindings = null, debt: availableDebt = null } = options;
399
+
400
+ // Step 1: Resolve base revision (real attribution)
401
+ const base = resolveBaseRevision(root, scenarioInput.base);
402
+
403
+ // Step 2: Compute current impact
404
+ const currentImpact = computeImpact(projectName, graph);
405
+
406
+ let currentConstraintImpact = null;
407
+ if (config && config.depConstraints) {
408
+ currentConstraintImpact = computeImpactConstraints(
409
+ projectName,
410
+ currentImpact.dependents,
411
+ graph.nodes,
412
+ graph.dependencies,
413
+ config.depConstraints,
414
+ );
415
+ }
416
+
417
+ // Step 3: Apply scenario changes to the graph
418
+ const { graph: scenarioGraph, applied, refused } = applyChanges(graph, scenarioInput.changes);
419
+
420
+ // Step 4: Compute scenario impact
421
+ const scenarioImpact = computeImpact(projectName, scenarioGraph);
422
+
423
+ let scenarioConstraintImpact = null;
424
+ if (config && config.depConstraints) {
425
+ scenarioConstraintImpact = computeImpactConstraints(
426
+ projectName,
427
+ scenarioImpact.dependents,
428
+ scenarioGraph.nodes,
429
+ scenarioGraph.dependencies,
430
+ config.depConstraints,
431
+ );
432
+ }
433
+
434
+ // Step 5: Build decision impact for both sides
435
+ const currentDecisionImpact = buildScenarioDecisionImpact(root, currentConstraintImpact, config);
436
+ const scenarioDecisionImpact = buildScenarioDecisionImpact(
437
+ root,
438
+ scenarioConstraintImpact,
439
+ config,
440
+ );
441
+
442
+ // Step 6: Build evolution alignment for both sides
443
+ const currentResolved = currentDecisionImpact
444
+ ? currentDecisionImpact.decisions.map((d) => d.id)
445
+ : [];
446
+ const scenarioResolved = scenarioDecisionImpact
447
+ ? scenarioDecisionImpact.decisions.map((d) => d.id)
448
+ : [];
449
+
450
+ const currentEvolution = buildScenarioEvolutionAlignment(
451
+ projectName,
452
+ currentImpact,
453
+ currentConstraintImpact,
454
+ currentResolved,
455
+ );
456
+ const scenarioEvolution = buildScenarioEvolutionAlignment(
457
+ projectName,
458
+ scenarioImpact,
459
+ scenarioConstraintImpact,
460
+ scenarioResolved,
461
+ );
462
+
463
+ // Step 7: Compute delta
464
+ const currentState = {
465
+ impact: currentImpact,
466
+ constraintImpact: currentConstraintImpact,
467
+ decisionImpact: currentDecisionImpact,
468
+ };
469
+ const scenarioState = {
470
+ impact: scenarioImpact,
471
+ constraintImpact: scenarioConstraintImpact,
472
+ decisionImpact: scenarioDecisionImpact,
473
+ };
474
+ const delta = computeDelta(currentState, scenarioState);
475
+
476
+ // Step 8: Build evidence chain
477
+ const evidenceChain = {
478
+ baseRevision: base.revision,
479
+ appliedChanges: applied,
480
+ currentState: "current",
481
+ scenarioState: "scenario",
482
+ delta: {
483
+ dependentsAdded: delta.dependentsAdded,
484
+ dependentsRemoved: delta.dependentsRemoved,
485
+ constraintsChanged: delta.constraintsChanged,
486
+ decisionsChanged: delta.decisionsChanged,
487
+ },
488
+ };
489
+
490
+ // Step 9: Evaluate governance impact on scenario (hypothetical re-evaluation)
491
+ const scenarioAffectedProjects = [projectName, ...scenarioImpact.dependents];
492
+ let scenarioFindings = null;
493
+ let scenarioDebt = null;
494
+ if (availableFindings) {
495
+ // Re-filter findings for the hypothetical graph's affected projects
496
+ const affectedSet = new Set(scenarioAffectedProjects);
497
+ scenarioFindings = availableFindings.filter((f) => {
498
+ const source = f.source ?? f.project ?? "";
499
+ const target = f.target ?? "";
500
+ return affectedSet.has(source) || affectedSet.has(target);
501
+ });
502
+ }
503
+ if (availableDebt) {
504
+ const affectedSet = new Set(scenarioAffectedProjects);
505
+ scenarioDebt = availableDebt.filter((d) => {
506
+ if (d.kind === "drift" || d.kind === "unresolved") {
507
+ return affectedSet.has(d.source ?? "");
508
+ }
509
+ return false;
510
+ });
511
+ }
512
+
513
+ // Step 10: Build notes — coverage and completeness
514
+ const notes = [
515
+ "virtual evaluation — not authoritative",
516
+ "this scenario has not been committed; run `check` for the real verdict",
517
+ ];
518
+ if (config && config.depConstraints) {
519
+ notes.push(
520
+ "constraint impact covers only depConstraints (3 of 15 violation types). " +
521
+ "A project with no violations here may still violate other rules.",
522
+ );
523
+ }
524
+ if (!availableFindings) {
525
+ notes.push(
526
+ "finding impact not re-evaluated — no findings data provided to scenario. " +
527
+ "Pass findings data for complete governance re-evaluation.",
528
+ );
529
+ }
530
+ if (!availableDebt) {
531
+ notes.push(
532
+ "debt impact not re-evaluated — no debt data provided to scenario. " +
533
+ "Pass debt data for complete governance re-evaluation.",
534
+ );
535
+ }
536
+ if (refused.length > 0) {
537
+ notes.push(`changes that could not be applied: ${refused.join("; ")}`);
538
+ }
539
+ // Step 11: Assemble — determine provenance and completeness semantics
540
+ // `complete` reflects whether the core evaluation (graph + constraints)
541
+ // completed. `governanceComplete` tracks whether governance dimensions
542
+ // were fully evaluated.
543
+ const governanceComplete = availableFindings !== null && availableDebt !== null;
544
+ const isComplete = refused.length === 0;
545
+
546
+ return {
547
+ virtual: true,
548
+ notAuthoritative: true,
549
+ project: projectName,
550
+ base,
551
+ changes: applied,
552
+ refused: refused.length > 0 ? refused : undefined,
553
+ evidenceChain,
554
+ current: {
555
+ impact: {
556
+ project: currentImpact.project,
557
+ direct: currentImpact.direct,
558
+ transitive: currentImpact.transitive,
559
+ dependents: currentImpact.dependents,
560
+ },
561
+ constraintImpact: currentConstraintImpact,
562
+ decisionImpact: currentDecisionImpact,
563
+ evolutionAlignment: currentEvolution,
564
+ },
565
+ scenario: {
566
+ impact: {
567
+ project: scenarioImpact.project,
568
+ direct: scenarioImpact.direct,
569
+ transitive: scenarioImpact.transitive,
570
+ dependents: scenarioImpact.dependents,
571
+ },
572
+ constraintImpact: scenarioConstraintImpact,
573
+ decisionImpact: scenarioDecisionImpact,
574
+ evolutionAlignment: scenarioEvolution,
575
+ ...(scenarioFindings !== null ? { findings: scenarioFindings } : {}),
576
+ ...(scenarioDebt !== null ? { debt: scenarioDebt } : {}),
577
+ },
578
+ governanceImpact: {
579
+ findingsReEvaluated: availableFindings !== null,
580
+ debtReEvaluated: availableDebt !== null,
581
+ governanceComplete,
582
+ scenarioFindingsCount: scenarioFindings?.length ?? 0,
583
+ scenarioDebtCount: scenarioDebt?.length ?? 0,
584
+ },
585
+ delta,
586
+ complete: isComplete,
587
+ notes,
588
+ };
589
+ }
590
+
591
+ /**
592
+ * Validates and parses a scenario input from a JSON string.
593
+ *
594
+ * @param {string} jsonString The raw JSON string.
595
+ * @returns {ScenarioInput}
596
+ * @throws {Error} When the input is invalid.
597
+ */
598
+ export function parseScenarioInput(jsonString) {
599
+ let parsed;
600
+ try {
601
+ parsed = JSON.parse(jsonString);
602
+ } catch (cause) {
603
+ throw new Error(`scenario: invalid JSON — ${cause.message}`, { cause });
604
+ }
605
+
606
+ if (!parsed || typeof parsed !== "object") {
607
+ throw new Error("scenario: input must be a JSON object");
608
+ }
609
+
610
+ const changes = parsed.changes;
611
+ if (!Array.isArray(changes)) {
612
+ throw new Error("scenario: 'changes' must be an array");
613
+ }
614
+
615
+ if (changes.length === 0) {
616
+ throw new Error("scenario: 'changes' must contain at least one change");
617
+ }
618
+
619
+ for (let i = 0; i < changes.length; i++) {
620
+ const change = changes[i];
621
+ if (!change || typeof change !== "object") {
622
+ throw new Error(`scenario: changes[${i}] must be an object`);
623
+ }
624
+ if (!SCENARIO_CHANGE_TYPES.includes(change.type)) {
625
+ throw new Error(
626
+ `scenario: changes[${i}].type "${change.type}" is not supported — ` +
627
+ `supported types: ${SCENARIO_CHANGE_TYPES.join(", ")}`,
628
+ );
629
+ }
630
+ if (typeof change.source !== "string" || change.source.trim() === "") {
631
+ throw new Error(`scenario: changes[${i}].source must be a non-empty string`);
632
+ }
633
+ if (typeof change.target !== "string" || change.target.trim() === "") {
634
+ throw new Error(`scenario: changes[${i}].target must be a non-empty string`);
635
+ }
636
+ }
637
+
638
+ return {
639
+ base: typeof parsed.base === "string" ? parsed.base : undefined,
640
+ changes: changes.map((c) => ({ type: c.type, source: c.source, target: c.target })),
641
+ };
642
+ }