// Pure SysML model validator. // // 13 rules, grouped: // - Structural (S1–S5) — referential integrity. Errors. // - Semantic (M1–M5) — SysML metamodel rules. Mostly errors. // - Traceability (T1–T2) — soft warnings on coverage. // // All rules are described in docs/sysml-modeling.md §7. // Pure function; safe to call repeatedly. Output is element-anchored so the // UI can dot/highlight the offending block, association, etc. import type { SysMLModel, Block, Association, Constraint, Requirement, } from "./model"; export type Severity = "error" | "warning" | "soft"; export type IssueAnchor = | { kind: "block"; id: string } | { kind: "association"; id: string } | { kind: "constraint"; id: string } | { kind: "requirement"; id: string } | { kind: "property"; blockId: string; propertyId: string } | { kind: "term"; id: string } | { kind: "model" }; export interface ValidationIssue { /** Stable rule code: S1, M2, T1, etc. */ code: string; severity: Severity; message: string; anchor: IssueAnchor; } export function validate(model: SysMLModel): ValidationIssue[] { const issues: ValidationIssue[] = []; const blockIds = new Set(model.blocks.map(b => b.id)); const reqIds = new Set(model.requirements.map(r => r.id)); // Lookups const blockById = new Map(model.blocks.map(b => [b.id, b])); // ─── Structural ──────────────────────────────────────────────────────── // S1 — every association.fromBlockId / toBlockId resolves to a block for (const a of model.associations) { if (!blockIds.has(a.fromBlockId)) { issues.push(s1IssueFor(a, "from", a.fromBlockId)); } if (!blockIds.has(a.toBlockId)) { issues.push(s1IssueFor(a, "to", a.toBlockId)); } } // S2 — every constraint.appliesTo entry resolves to a block for (const c of model.constraints) { for (const target of c.appliesTo) { if (!blockIds.has(target)) { issues.push({ code: "S2", severity: "error", message: `Constraint "${c.label}" applies to unknown block "${target}".`, anchor: { kind: "constraint", id: c.id }, }); } } } // S3 — every requirement satisfy.blockId resolves to a block // S4 — every requirement derive.fromReqId resolves to a requirement for (const r of model.requirements) { for (const rel of r.relations) { if (rel.kind === "satisfy" && !blockIds.has(rel.blockId)) { issues.push({ code: "S3", severity: "error", message: `Requirement ${r.tag} is satisfied by unknown block "${rel.blockId}".`, anchor: { kind: "requirement", id: r.id }, }); } if (rel.kind === "derive" && !reqIds.has(rel.fromReqId)) { issues.push({ code: "S4", severity: "error", message: `Requirement ${r.tag} derives from unknown requirement "${rel.fromReqId}".`, anchor: { kind: "requirement", id: r.id }, }); } } } // S5 — block ids unique; requirement tags unique; association ids unique uniquenessIssues(model.blocks, b => b.id, "S5", "block id", b => ({ kind: "block", id: b.id })).forEach(i => issues.push(i)); uniquenessIssues(model.associations, a => a.id, "S5", "association id", a => ({ kind: "association", id: a.id })).forEach(i => issues.push(i)); uniquenessIssues(model.constraints, c => c.id, "S5", "constraint id", c => ({ kind: "constraint", id: c.id })).forEach(i => issues.push(i)); uniquenessIssues(model.requirements, r => r.tag, "S5", "requirement tag", r => ({ kind: "requirement", id: r.id })).forEach(i => issues.push(i)); // ─── Semantic (SysML metamodel) ──────────────────────────────────────── // M1 — generalization is acyclic cycles( model.blocks, block => model.associations.filter(a => a.kind === "generalization" && a.fromBlockId === block.id).map(a => a.toBlockId), blockById ).forEach(cycle => issues.push({ code: "M1", severity: "error", message: `Cyclic generalization: ${cycle.join(" → ")} → ${cycle[0]}`, anchor: { kind: "block", id: cycle[0]! }, }) ); // M2 — composition is acyclic cycles( model.blocks, block => model.associations.filter(a => a.kind === "composition" && a.fromBlockId === block.id).map(a => a.toBlockId), blockById ).forEach(cycle => issues.push({ code: "M2", severity: "error", message: `Cyclic composition: ${cycle.join(" → ")} → ${cycle[0]}`, anchor: { kind: "block", id: cycle[0]! }, }) ); // M3 — exactly zero or one block has kind "system" const systems = model.blocks.filter(b => b.kind === "system"); if (systems.length > 1) { for (const s of systems) { issues.push({ code: "M3", severity: "error", message: `Multiple System-of-Interest blocks: ${systems.map(x => x.label).join(", ")}.`, anchor: { kind: "block", id: s.id }, }); } } // M4 — constraint blocks may not participate in composition for (const a of model.associations) { if (a.kind !== "composition") continue; const from = blockById.get(a.fromBlockId); const to = blockById.get(a.toBlockId); if ((from && from.kind === "constraint") || (to && to.kind === "constraint")) { issues.push({ code: "M4", severity: "error", message: `Composition involves a constraint block (${from?.kind === "constraint" ? from.label : to?.label}).`, anchor: { kind: "association", id: a.id }, }); } } // M5 — property names unique within a block for (const b of model.blocks) { const seen = new Map(); for (const p of b.properties) { seen.set(p.name, (seen.get(p.name) ?? 0) + 1); } for (const [name, count] of seen) { if (count > 1) { issues.push({ code: "M5", severity: "error", message: `Block "${b.label}" has duplicate property "${name}" (×${count}).`, anchor: { kind: "block", id: b.id }, }); } } } // ─── Traceability ────────────────────────────────────────────────────── // T1 — every requirement has at least one satisfy relation for (const r of model.requirements) { const hasSatisfier = r.relations.some(rel => rel.kind === "satisfy"); if (!hasSatisfier) { issues.push({ code: "T1", severity: "warning", message: `Requirement ${r.tag} has no satisfying block.`, anchor: { kind: "requirement", id: r.id }, }); } } // T2 — every block is referenced by ≥1 association OR satisfies ≥1 requirement // (constraint blocks are exempt — they don't need to be referenced via association) const referencedBlockIds = new Set(); for (const a of model.associations) { referencedBlockIds.add(a.fromBlockId); referencedBlockIds.add(a.toBlockId); } for (const r of model.requirements) { for (const rel of r.relations) { if (rel.kind === "satisfy") referencedBlockIds.add(rel.blockId); } } for (const c of model.constraints) { for (const target of c.appliesTo) referencedBlockIds.add(target); } for (const b of model.blocks) { if (b.kind === "constraint") continue; if (!referencedBlockIds.has(b.id)) { issues.push({ code: "T2", severity: "soft", message: `Block "${b.label}" is not referenced by any association, constraint, or requirement.`, anchor: { kind: "block", id: b.id }, }); } } return issues; } // ─── Helpers ───────────────────────────────────────────────────────────── function s1IssueFor(a: Association, side: "from" | "to", missingId: string): ValidationIssue { return { code: "S1", severity: "error", message: `Association "${a.label || a.id}" ${side} unknown block "${missingId}".`, anchor: { kind: "association", id: a.id }, }; } function uniquenessIssues( items: T[], keyOf: (item: T) => string, code: string, what: string, anchorOf: (item: T) => IssueAnchor ): ValidationIssue[] { const counts = new Map(); for (const it of items) counts.set(keyOf(it), (counts.get(keyOf(it)) ?? 0) + 1); const issues: ValidationIssue[] = []; for (const it of items) { const k = keyOf(it); if ((counts.get(k) ?? 0) > 1) { issues.push({ code, severity: "error", message: `Duplicate ${what} "${k}".`, anchor: anchorOf(it), }); } } return issues; } /** * Find all simple cycles in a directed graph, returning each cycle as an * array of node ids. Uses DFS with a recursion stack (Tarjan-style). * Each cycle is reported once, starting from its lexicographically smallest * node (so duplicates from different start positions collapse). */ function cycles( nodes: T[], outgoing: (node: T) => string[], byId: Map ): string[][] { const result: string[][] = []; const seenCycles = new Set(); function dfs(currentId: string, path: string[], onPath: Set) { const node = byId.get(currentId); if (!node) return; for (const nextId of outgoing(node)) { if (onPath.has(nextId)) { // Found a cycle from nextId back to itself through the path const startIdx = path.indexOf(nextId); if (startIdx >= 0) { const cycle = path.slice(startIdx); const key = canonicalCycleKey(cycle); if (!seenCycles.has(key)) { seenCycles.add(key); result.push(cycle); } } continue; } onPath.add(nextId); path.push(nextId); dfs(nextId, path, onPath); path.pop(); onPath.delete(nextId); } } for (const n of nodes) { dfs(n.id, [n.id], new Set([n.id])); } return result; } function canonicalCycleKey(cycle: string[]): string { // Rotate so the smallest id is first; then join. let minIdx = 0; for (let i = 1; i < cycle.length; i++) { if (cycle[i]! < cycle[minIdx]!) minIdx = i; } return [...cycle.slice(minIdx), ...cycle.slice(0, minIdx)].join("→"); } // ─── Convenience accessors used by the UI ──────────────────────────────── export function severityRank(s: Severity): number { return s === "error" ? 3 : s === "warning" ? 2 : 1; } export function maxSeverity(issues: ValidationIssue[]): Severity | null { let best: Severity | null = null; for (const i of issues) { if (best === null || severityRank(i.severity) > severityRank(best)) best = i.severity; } return best; }