// Pre-apply impact analysis: given the current model + a candidate set of // ops, simulate the apply (without persisting) and report what would change. // // Used for the M7 proposal card so the PM can see consequences before // approving. Composed of: // - structural diff (added/removed/changed elements) // - validation diff (issues created/resolved) // - dep-graph blast radius (closure of element ids touched) import { applyOps } from "../sync/applyOps"; import { validate, type ValidationIssue } from "./validate"; import { buildDepGraph, dependentsOf } from "./depgraph"; import type { SysMLModel } from "./model"; import type { ModelOp } from "../sync/ops"; export interface ElementSummary { id: string; kind: "block" | "association" | "constraint" | "requirement" | "property"; label: string; } export interface ImpactSummary { /** Was the candidate apply legal at all? Errors mean something rejected. */ ok: boolean; errors: Array<{ opIndex: number; code: string; message: string }>; added: ElementSummary[]; removed: ElementSummary[]; changed: ElementSummary[]; issuesCreated: ValidationIssue[]; issuesResolved: ValidationIssue[]; /** All element ids in the dep-graph closure of the touched elements. */ blastRadius: string[]; /** Compact stats for headline display. */ stats: { blocksDelta: number; associationsDelta: number; constraintsDelta: number; requirementsDelta: number; issuesDelta: number; }; } export function analyzeImpact(currentModel: SysMLModel, ops: ModelOp[]): ImpactSummary { const beforeIssues = validate(currentModel); const result = applyOps(currentModel, ops); if (!result.applied) { return { ok: false, errors: result.errors, added: [], removed: [], changed: [], issuesCreated: [], issuesResolved: [], blastRadius: [], stats: { blocksDelta: 0, associationsDelta: 0, constraintsDelta: 0, requirementsDelta: 0, issuesDelta: 0 }, }; } const after = result.model; const afterIssues = validate(after); const beforeBlocks = idMap(currentModel.blocks); const afterBlocks = idMap(after.blocks); const beforeAssocs = idMap(currentModel.associations); const afterAssocs = idMap(after.associations); const beforeCons = idMap(currentModel.constraints); const afterCons = idMap(after.constraints); const beforeReqs = idMap(currentModel.requirements); const afterReqs = idMap(after.requirements); const added: ElementSummary[] = []; const removed: ElementSummary[] = []; const changed: ElementSummary[] = []; // Blocks for (const [id, b] of afterBlocks) { const prev = beforeBlocks.get(id); if (!prev) added.push({ id, kind: "block", label: b.label }); else if (prev.label !== b.label || prev.kind !== b.kind || prev.properties.length !== b.properties.length) { changed.push({ id, kind: "block", label: b.label }); } } for (const [id, b] of beforeBlocks) { if (!afterBlocks.has(id)) removed.push({ id, kind: "block", label: b.label }); } // Associations for (const [id, a] of afterAssocs) { const prev = beforeAssocs.get(id); if (!prev) added.push({ id, kind: "association", label: a.label || `${a.fromBlockId}→${a.toBlockId}` }); else if (prev.label !== a.label || prev.kind !== a.kind || prev.fromBlockId !== a.fromBlockId || prev.toBlockId !== a.toBlockId) { changed.push({ id, kind: "association", label: a.label || `${a.fromBlockId}→${a.toBlockId}` }); } } for (const [id, a] of beforeAssocs) { if (!afterAssocs.has(id)) removed.push({ id, kind: "association", label: a.label || `${a.fromBlockId}→${a.toBlockId}` }); } // Constraints for (const [id, c] of afterCons) { const prev = beforeCons.get(id); if (!prev) added.push({ id, kind: "constraint", label: c.label }); else if (prev.label !== c.label || prev.expression !== c.expression || prev.appliesTo.length !== c.appliesTo.length) { changed.push({ id, kind: "constraint", label: c.label }); } } for (const [id, c] of beforeCons) { if (!afterCons.has(id)) removed.push({ id, kind: "constraint", label: c.label }); } // Requirements for (const [id, r] of afterReqs) { const prev = beforeReqs.get(id); if (!prev) added.push({ id, kind: "requirement", label: r.tag }); else if (prev.tag !== r.tag || prev.text !== r.text || prev.relations.length !== r.relations.length) { changed.push({ id, kind: "requirement", label: r.tag }); } } for (const [id, r] of beforeReqs) { if (!afterReqs.has(id)) removed.push({ id, kind: "requirement", label: r.tag }); } // Validation diff (by message — issues carry no stable id) const beforeKeys = new Set(beforeIssues.map(issueKey)); const afterKeys = new Set(afterIssues.map(issueKey)); const issuesCreated = afterIssues.filter(i => !beforeKeys.has(issueKey(i))); const issuesResolved = beforeIssues.filter(i => !afterKeys.has(issueKey(i))); // Dep-graph blast radius — closure over each touched element on the AFTER graph const graph = buildDepGraph(after); const touched = new Set([ ...added.map(e => e.id), ...removed.map(e => e.id), ...changed.map(e => e.id), ]); const blast = new Set(); for (const id of touched) { blast.add(id); for (const r of dependentsOf(graph, id)) blast.add(r); } return { ok: true, errors: [], added, removed, changed, issuesCreated, issuesResolved, blastRadius: [...blast], stats: { blocksDelta: after.blocks.length - currentModel.blocks.length, associationsDelta: after.associations.length - currentModel.associations.length, constraintsDelta: after.constraints.length - currentModel.constraints.length, requirementsDelta: after.requirements.length - currentModel.requirements.length, issuesDelta: afterIssues.length - beforeIssues.length, }, }; } function idMap(items: T[]): Map { return new Map(items.map(i => [i.id, i])); } function issueKey(i: ValidationIssue): string { return `${i.code}|${i.severity}|${i.message}`; }